Skip to content

Global Player Settings System

🧀 Harold edited this page Nov 8, 2025 · 1 revision

Fulcrum persists every cross network preference in the core players document. The shared PlayerSettingsService facade exposes type safe helpers so Paper and Velocity features stay consistent while delegating persistence and cache coordination to the data layer (common-api/src/main/java/sh/harold/fulcrum/common/settings/PlayerSettingsService.java:14).

Facade overview

  • The Paper runtime registers RuntimePlayerSettingsService during PlayerDataFeature.initialize, wiring the facade into the container and service locator (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/PlayerDataFeature.java:72).
  • Velocity mirrors the same registration via VelocityPlayerDataFeature, exposing the identical API to proxy modules (runtime-velocity/src/main/java/sh/harold/fulcrum/velocity/fundamentals/data/VelocityPlayerDataFeature.java:46).
  • Both implementations delegate to DataAPI for Mongo persistence and reuse the session cache so hot reads avoid round trips (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:17, runtime-velocity/src/main/java/sh/harold/fulcrum/velocity/fundamentals/data/VelocityPlayerSettingsService.java:17).

Quick start

public final class DebugTraceCommand {
    private DebugTraceCommand() {
    }

    public static LiteralCommandNode<CommandSourceStack> build(PlayerSettingsService settings) {
        return Commands.literal("debugtrace")
                .requires(stack -> stack.getSender() instanceof Player)
                .executes(ctx -> {
                    Player player = ctx.getSource().getPlayer();
                    UUID playerId = player.getUniqueId();

                    settings.getDebugLevel(playerId).thenAccept(level -> {
                        Component feedback = Component.text("Debug tier: " + level.name(), NamedTextColor.GRAY);
                        player.sendMessage(feedback);
                    });
                    return Command.SINGLE_SUCCESS;
                })
                .build();
    }
}

// During plugin bootstrap
CommandRegistrar.register(DebugTraceCommand.build(playerSettingsService));

Request the facade from the dependency container or service locator- both runtimes register it during bootstrap- then operate on the returned CompletionStage. Implementations complete immediately today but stay async-safe for future I/O. The command itself is built with Paper’s Brigadier builder (io.papermc.paper.command.brigadier.Commands) and registered through CommandRegistrar, matching Fulcrum’s standard command lifecycle.

When a feature needs experience scoped values, call forFamily(String) (or forScope(family, variant) when you need a mode specific fork). Those writes land in the player_data_<family> collection instead of the core players document, so global preferences stay separate from game metadata.

Core Settings

PlayerSettingsService writes into players.<uuid>.settings.*, creating the document on demand if it does not exist (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:82). Default joins seed a minimal payload when the core document is created (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/PlayerDataFeature.java:43).

Example document after a few mutations:

{
  "_id": "9c5c4e99-7c1f-41f9-9b77-58e5e8fa2216",
  "username": "SeasonalSpout",
  "settings": {
    "debug": {
      "level": "PLAYER"
    },
    "privacy": {
      "partyInvites": "LOW"
    }
  }
}

Values are stored as plain strings or primitives; callers always interact with typed enums or optionals through the facade.

Reading settings

  • getDebugLevel(UUID) resolves the current tier, first consulting the session cache, then falling back to Mongo if the cache reports NONE (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:29).
  • isDebugEnabled(UUID) completes with a boolean shortcut based on the tier (common-api/src/main/java/sh/harold/fulcrum/common/settings/PlayerSettingsService.java:33).
  • getLevel(UUID, key, fallback) reads tiered settings (SettingLevel) for paths like privacy.partyInvites and returns the provided fallback when unset (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:49).

Never cache the results yourself-hold onto the CompletionStage or react to it inside the callback so you stay in sync with the session service.

Mutating settings

  • Toggle debug output with setDebugLevel(UUID, PlayerDebugLevel) or setDebugEnabled(UUID, boolean); both sanitize input, persist to Mongo (settings.debug.level), and push the tier into the session cache (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:38).
  • Persist coarse-grained privacy or matchmaking tiers via setLevel(UUID, key, SettingLevel). Data is stored as the enum name and remains compatible with the cross-service SettingLevel contract (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:56).
  • Use forGame(String) or forFamily(String) when you need minigame or family scoped payloads. These helpers store values in the dedicated family collection (player_data_<family>/<uuid>/settings...), keeping scoped data out of the core players document while exposing get, set, getAll, and remove helpers (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:88).

Writes return already-completed futures right now, but your handlers should still treat them asynchronously so the implementation can evolve without breaking callers:

playerSettings.setLevel(playerId, "privacy.partyInvites", SettingLevel.HIGH)
        .thenRun(() -> player.sendMessage(Component.text("Party invites set to HIGH")));

Storage and caching notes

  • The facade backfills missing documents and settings blocks before writing (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:82), so modules never need to guard for first-join cases.
  • Debug tiers propagate through PlayerSessionService, letting listeners check sessionService.getDebugLevel(UUID) without hitting Mongo (runtime/src/main/java/sh/harold/fulcrum/fundamentals/session/PlayerSessionService.java:205).
  • Avoid direct DataAPI mutations of players.settings.*; bypassing the facade skips cache priming and the sanitizer for PlayerDebugLevel.
  • Use descriptive keys and keep core settings global game-specific knobs belong under the scoped accessor so they stay isolated inside their own collection.

Setting types

Built in enums ship with the system:

  • PlayerDebugLevel: NONE, PLAYER, COUNCIL, STAFF; controls how much diagnostic output a player receives.
  • SettingLevel: NONE, LOW, MEDIUM, HIGH, MAX; covers privacy, matchmaking, or any coarse tier.

All values are persisted as JSON friendly primitives:

  • Strings: enum names, lightweight toggles ("STAFF", "archer").
  • Booleans and numbers: direct primitives (true, 3).
  • Lists: ordered collections such as disabled channels or preferred maps (["trade", "global"]).
  • Maps: structured payloads like loadouts or UI layouts (nested key/value pairs).

The facade automatically casts enums when you call getSetting(..., SomeEnum.class) and returns optionals for everything else. Prefer simple, schema free JSON shapes so other services can consume the same documents.

Usage examples

// Toggle a global debug tier; persists players/<uuid>/settings.debug.level
playerSettings.setDebugLevel(playerId, PlayerDebugLevel.STAFF);

playerSettings.getDebugLevel(playerId).thenAccept(level -> {
    if (level.isEnabled()) {
        player.sendMessage(Component.text("Debug enabled", NamedTextColor.GRAY));
    }
});
// Gate party invites with a SettingLevel (players/<uuid>/settings.privacy.partyInvites)
playerSettings.setLevel(playerId, "privacy.partyInvites", SettingLevel.LOW);

playerSettings.getLevel(playerId, "privacy.partyInvites", SettingLevel.MEDIUM)
        .thenAccept(level -> updateInviteUi(playerId, level));
// Mute chat channels using a list (players/<uuid>/settings.chat.channels.disabled)
List<String> muted = List.of("trade", "global");
playerSettings.setSetting(playerId, "chat.channels.disabled", muted);

playerSettings.getSetting(playerId, "chat.channels.disabled", List.class)
        .thenAccept(opt -> opt.ifPresent(ids -> ids.forEach(channelService::muteChannel)));
// Store a bridge loadout shared by every variant (player_data_bridge/<uuid>/settings.loadout)
GameSettingsScope bridge = playerSettings.forGame("bridge");

Map<String, Object> loadout = Map.of(
        "slot0", "STONE_SWORD",
        "slot1", "WOOL",
        "slot2", List.of("GOLDEN_APPLE", "SPLASH_POTION")
);
bridge.set(playerId, "loadout", loadout);

bridge.get(playerId, "loadout", Map.class)
        .thenAccept(opt -> opt.ifPresent(this::applyLoadout));
// Variant specific override (player_data_bridge/<uuid>/settings.ranked.kit.preferred)
GameSettingsScope rankedBridge = playerSettings.forScope("bridge", "ranked");

rankedBridge.set(playerId, "kit.preferred", "archer");

rankedBridge.get(playerId, "kit.preferred", String.class)
        .thenAccept(opt -> opt.ifPresent(this::applyRankedKit));

Validation checklist

  • Inspect the players collection after toggling a setting to confirm the nested path (mongo shell, db.players.findOne({ _id: "<uuid>" })).
  • Exercise the facade from Paper and Velocity to ensure both runtimes observe the same tier.
  • Restart runtime nodes and verify cached tiers survive reloads (session cache repopulates from Mongo).
  • For minigame payloads, call scope.getAll(UUID) and ensure the returned map matches the persisted JSON before relying on individual keys.

Clone this wiki locally