-
Notifications
You must be signed in to change notification settings - Fork 0
Global Player Settings System
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).
- The Paper runtime registers
RuntimePlayerSettingsServiceduringPlayerDataFeature.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
DataAPIfor 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).
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.
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.
-
getDebugLevel(UUID)resolves the current tier, first consulting the session cache, then falling back to Mongo if the cache reportsNONE(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 likeprivacy.partyInvitesand 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.
- Toggle debug output with
setDebugLevel(UUID, PlayerDebugLevel)orsetDebugEnabled(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-serviceSettingLevelcontract (runtime/src/main/java/sh/harold/fulcrum/fundamentals/playerdata/RuntimePlayerSettingsService.java:56). - Use
forGame(String)orforFamily(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 coreplayersdocument while exposingget,set,getAll, andremovehelpers (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")));- The facade backfills missing documents and
settingsblocks 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 checksessionService.getDebugLevel(UUID)without hitting Mongo (runtime/src/main/java/sh/harold/fulcrum/fundamentals/session/PlayerSessionService.java:205). - Avoid direct
DataAPImutations ofplayers.settings.*; bypassing the facade skips cache priming and the sanitizer forPlayerDebugLevel. - Use descriptive keys and keep core settings global game-specific knobs belong under the scoped accessor so they stay isolated inside their own collection.
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.
// 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));- Inspect the
playerscollection after toggling a setting to confirm the nested path (mongoshell,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.