Add gamerule per dimension

This commit is contained in:
Darto KLoning
2026-09-06 19:31:59 +07:00
parent e037fadfc9
commit ba4fc88538
11 changed files with 598 additions and 40 deletions
@@ -10,6 +10,7 @@ public final class ConfigPaths {
public static final Path PLAYER_POSITIONS = DIRECTORY.resolve("player-positions.json");
public static final Path PORTALS = DIRECTORY.resolve("portals.json");
public static final Path DIMENSION_GAMEMODES = DIRECTORY.resolve("dimension-gamemodes.json");
public static final Path DIMENSION_GAMERULES = DIRECTORY.resolve("dimension-gamerules.json");
private ConfigPaths() {}
}
@@ -0,0 +1,201 @@
package id.dartokloning.lobbysystems;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.gamerules.GameRule;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Persistent per-dimension gamerule overrides owned by Lobby Systems.
*
* Values are stored in config/lobby/dimension-gamerules.json and are applied
* directly to each ServerLevel's independent GameRules instance.
*/
public final class DimensionGameRuleManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Map<String, Map<String, String>> configured = new LinkedHashMap<>();
public boolean load() {
configured.clear();
try {
Files.createDirectories(ConfigPaths.DIRECTORY);
if (!Files.exists(ConfigPaths.DIMENSION_GAMERULES)) {
save();
return true;
}
String json = Files.readString(ConfigPaths.DIMENSION_GAMERULES, StandardCharsets.UTF_8);
if (json.isBlank()) {
save();
return true;
}
JsonElement rootElement = JsonParser.parseString(json);
if (!rootElement.isJsonObject()) {
LobbySystemsMod.LOGGER.error("dimension-gamerules.json root must be a JSON object");
return false;
}
for (Map.Entry<String, JsonElement> dimensionEntry : rootElement.getAsJsonObject().entrySet()) {
if (!dimensionEntry.getValue().isJsonObject()) {
LobbySystemsMod.LOGGER.warn("Ignoring gamerule config for '{}': expected an object", dimensionEntry.getKey());
continue;
}
Map<String, String> rules = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> ruleEntry : dimensionEntry.getValue().getAsJsonObject().entrySet()) {
JsonElement value = ruleEntry.getValue();
if (!value.isJsonPrimitive()) {
LobbySystemsMod.LOGGER.warn("Ignoring gamerule '{} / {}': value must be boolean, number, or string",
dimensionEntry.getKey(), ruleEntry.getKey());
continue;
}
rules.put(ruleEntry.getKey(), value.getAsString());
}
configured.put(dimensionEntry.getKey(), rules);
}
return true;
} catch (Exception e) {
LobbySystemsMod.LOGGER.error("Failed to load {}", ConfigPaths.DIMENSION_GAMERULES, e);
return false;
}
}
public boolean save() {
try {
Files.createDirectories(ConfigPaths.DIRECTORY);
JsonObject root = new JsonObject();
configured.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(dimensionEntry -> {
JsonObject rules = new JsonObject();
dimensionEntry.getValue().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(ruleEntry -> {
String value = ruleEntry.getValue();
if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
rules.addProperty(ruleEntry.getKey(), Boolean.parseBoolean(value));
} else {
try {
rules.addProperty(ruleEntry.getKey(), Integer.parseInt(value));
} catch (NumberFormatException ignored) {
rules.addProperty(ruleEntry.getKey(), value);
}
}
});
root.add(dimensionEntry.getKey(), rules);
});
Files.writeString(ConfigPaths.DIMENSION_GAMERULES, GSON.toJson(root) + System.lineSeparator(), StandardCharsets.UTF_8);
return true;
} catch (IOException e) {
LobbySystemsMod.LOGGER.error("Failed to save {}", ConfigPaths.DIMENSION_GAMERULES, e);
return false;
}
}
public Map<String, Map<String, String>> all() {
Map<String, Map<String, String>> copy = new LinkedHashMap<>();
configured.forEach((dimension, rules) -> copy.put(dimension, Map.copyOf(rules)));
return Map.copyOf(copy);
}
public boolean set(String dimensionId, String ruleId, String serializedValue) {
configured.computeIfAbsent(dimensionId, ignored -> new LinkedHashMap<>()).put(ruleId, serializedValue);
return save();
}
public boolean remove(String dimensionId, String ruleId) {
Map<String, String> rules = configured.get(dimensionId);
if (rules == null || rules.remove(ruleId) == null) {
return false;
}
if (rules.isEmpty()) {
configured.remove(dimensionId);
}
return save();
}
public ApplySummary applyAll(MinecraftServer server) {
int dimensions = 0;
int applied = 0;
int skipped = 0;
for (Map.Entry<String, Map<String, String>> dimensionEntry : configured.entrySet()) {
ServerLevel level = LobbySystemsMod.findLevelForManager(server, dimensionEntry.getKey());
if (level == null) {
skipped += dimensionEntry.getValue().size();
continue;
}
dimensions++;
ApplySummary summary = applyToLevel(level);
applied += summary.applied();
skipped += summary.skipped();
}
return new ApplySummary(dimensions, applied, skipped);
}
public ApplySummary applyToLevel(ServerLevel level) {
Map<String, String> rules = configured.get(level.dimension().identifier().toString());
if (rules == null || rules.isEmpty()) {
return new ApplySummary(1, 0, 0);
}
int applied = 0;
int skipped = 0;
for (Map.Entry<String, String> entry : rules.entrySet()) {
GameRule<?> rule = findRule(entry.getKey());
if (rule == null || !rule.isEnabled(level.enabledFeatures())) {
LobbySystemsMod.LOGGER.warn("Skipping configured gamerule '{}' for '{}': rule unavailable",
entry.getKey(), level.dimension().identifier());
skipped++;
continue;
}
if (applySerialized(level, rule, entry.getValue())) {
applied++;
} else {
LobbySystemsMod.LOGGER.warn("Skipping configured gamerule '{}'='{}' for '{}': invalid value",
entry.getKey(), entry.getValue(), level.dimension().identifier());
skipped++;
}
}
return new ApplySummary(1, applied, skipped);
}
public static GameRule<?> findRule(String rawId) {
String normalized = rawId.contains(":") ? rawId : "minecraft:" + rawId;
for (GameRule<?> rule : BuiltInRegistries.GAME_RULE) {
Identifier id = rule.getIdentifierWithFallback();
if (id.toString().equals(normalized) || rule.id().equals(rawId)) {
return rule;
}
}
return null;
}
public static boolean applySerialized(ServerLevel level, GameRule<?> rawRule, String serializedValue) {
return applySerializedTyped(level, rawRule, serializedValue);
}
private static <T> boolean applySerializedTyped(ServerLevel level, GameRule<T> rule, String serializedValue) {
var result = rule.deserialize(serializedValue).result();
if (result.isEmpty()) {
return false;
}
level.getGameRules().set(rule, result.get(), level.getServer());
return true;
}
public static <T> void resetToDefault(ServerLevel level, GameRule<T> rule) {
level.getGameRules().set(rule, rule.defaultValue(), level.getServer());
}
public record ApplySummary(int dimensions, int applied, int skipped) {}
}
@@ -4,7 +4,9 @@ import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
@@ -39,6 +41,7 @@ public final class LobbySystemsMod implements ModInitializer {
private static final PortalManager PORTALS = new PortalManager();
private static final TeleportCoordinator TELEPORTS = new TeleportCoordinator();
private static final DimensionGameModeManager DIMENSION_GAMEMODES = new DimensionGameModeManager();
private static final DimensionGameRuleManager DIMENSION_GAMERULES = new DimensionGameRuleManager();
private static int saveTicker;
@Override
@@ -76,6 +79,36 @@ public final class LobbySystemsMod implements ModInitializer {
"Configured dimensions: " + DIMENSION_GAMEMODES.all().size()
);
boolean gameRulesLoaded = DIMENSION_GAMERULES.load();
LobbyLog.state(
gameRulesLoaded ? "DIMENSION GAMERULES LOADED" : "DIMENSION GAMERULES LOAD FAILED",
"File: " + ConfigPaths.DIMENSION_GAMERULES,
"Configured dimensions: " + DIMENSION_GAMERULES.all().size(),
"Mode: native per-dimension"
);
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
DimensionGameRuleManager.ApplySummary summary = DIMENSION_GAMERULES.applyAll(server);
LobbyLog.state(
"DIMENSION GAMERULES APPLIED",
"Configured dimensions loaded: " + summary.dimensions(),
"Rules applied: " + summary.applied(),
"Rules skipped: " + summary.skipped()
);
});
ServerLevelEvents.LOAD.register((server, world) -> {
DimensionGameRuleManager.ApplySummary summary = DIMENSION_GAMERULES.applyToLevel(world);
if (summary.applied() > 0 || summary.skipped() > 0) {
LobbyLog.state(
"DIMENSION GAMERULES WORLD LOAD",
"Dimension: " + world.dimension().identifier(),
"Rules applied: " + summary.applied(),
"Rules skipped: " + summary.skipped()
);
}
});
ServerPlayConnectionEvents.JOIN.register((listener, sender, server) -> {
ServerPlayer player = listener.getPlayer();
server.execute(() -> {
@@ -149,6 +182,34 @@ public final class LobbySystemsMod implements ModInitializer {
true,
true))))
.then(Commands.literal("gamerule")
.then(Commands.literal("set")
.then(Commands.argument("dimension", IdentifierArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
ctx.getSource().getServer().levelKeys().stream().map(ResourceKey::identifier), builder))
.then(Commands.argument("rule", IdentifierArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
BuiltInRegistries.GAME_RULE.stream().map(GameRule::getIdentifierWithFallback), builder))
.then(Commands.argument("value", StringArgumentType.word())
.suggests((ctx, builder) -> suggestGameRuleValues(
IdentifierArgument.getId(ctx, "rule").toString(), builder))
.executes(ctx -> setDimensionGameRule(
ctx.getSource(),
ctx.getSource().getServer(),
IdentifierArgument.getId(ctx, "dimension").toString(),
IdentifierArgument.getId(ctx, "rule").toString(),
StringArgumentType.getString(ctx, "value")))))))
.then(Commands.literal("reset")
.then(Commands.argument("dimension", IdentifierArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
ctx.getSource().getServer().levelKeys().stream().map(ResourceKey::identifier), builder))
.then(Commands.argument("rule", IdentifierArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
BuiltInRegistries.GAME_RULE.stream().map(GameRule::getIdentifierWithFallback), builder))
.executes(ctx -> resetDimensionGameRule(
ctx.getSource(),
ctx.getSource().getServer(),
IdentifierArgument.getId(ctx, "dimension").toString(),
IdentifierArgument.getId(ctx, "rule").toString())))))
.then(Commands.argument("dimension", IdentifierArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
ctx.getSource().getServer().levelKeys().stream().map(ResourceKey::identifier), builder))
@@ -300,6 +361,14 @@ public final class LobbySystemsMod implements ModInitializer {
DIMENSION_GAMEMODES.applyToDimension(server, dimension);
}
boolean gameRulesOk = DIMENSION_GAMERULES.load();
if (!gameRulesOk) {
source.sendFailure(Component.literal("§eConfig utama direload, tetapi dimension-gamerules.json bermasalah. Cek console."));
LobbyLog.state("RELOAD FAILED", "Reason: dimension-gamerules.json gagal dibaca");
return 0;
}
DimensionGameRuleManager.ApplySummary gameRuleSummary = DIMENSION_GAMERULES.applyAll(server);
if (source.getEntity() instanceof ServerPlayer player) {
player.sendSystemMessage(Component.literal(
"§aLobby Systems direload. §7Lobby: §f" + CONFIG.get().lobbyDimension
@@ -312,6 +381,7 @@ public final class LobbySystemsMod implements ModInitializer {
"Minimum play: " + CONFIG.get().minimumPlaySeconds + "s",
"Portals: " + PORTALS.size(),
"Dimension gamemodes: " + DIMENSION_GAMEMODES.all().size(),
"Dimension gamerules: " + DIMENSION_GAMERULES.all().size() + " dimensions / " + gameRuleSummary.applied() + " rules applied",
"Player positions lobby exclusion: active"
);
return 1;
@@ -362,11 +432,13 @@ public final class LobbySystemsMod implements ModInitializer {
int start = (page - 1) * GAMERULES_PER_PAGE;
int end = Math.min(start + GAMERULES_PER_PAGE, entries.size());
sendCommandMessage(source, Component.literal("§8§m----------------------------------------"));
sendCommandMessage(source, Component.literal(
"§6§lLobby Systems Gamerules §r§7[§f" + page + "§7/§f" + totalPages + "§7]"
+ "\n§7Dimension: §f" + level.dimension().identifier()
+ " §8| §7Rules: §f" + entries.size()
+ (skippedRules > 0 ? " §8| §7Skipped: §e" + skippedRules : "")));
sendCommandMessage(source, Component.literal("§8§m----------------------------------------"));
for (int i = start; i < end; i++) {
GameRuleEntry entry = entries.get(i);
@@ -374,10 +446,13 @@ public final class LobbySystemsMod implements ModInitializer {
sendCommandMessage(source, Component.literal("§8• §f" + entry.name() + " §7= " + valueColor + entry.value()));
}
sendCommandMessage(source, Component.literal("§8§m----------------------------------------"));
if (page < totalPages) {
int next = page + 1;
sendCommandMessage(source, Component.literal(
"§7Halaman selanjutnya: §f/ls gamerule " + level.dimension().identifier() + " --page " + next));
} else if (page > 1) {
sendCommandMessage(source, Component.literal("§7Halaman terakhir."));
}
return entries.size();
}
@@ -395,6 +470,106 @@ public final class LobbySystemsMod implements ModInitializer {
}
}
private static java.util.concurrent.CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestGameRuleValues(
String ruleId,
com.mojang.brigadier.suggestion.SuggestionsBuilder builder) {
GameRule<?> rule = DimensionGameRuleManager.findRule(ruleId);
if (rule != null && rule.valueClass() == Boolean.class) {
return SharedSuggestionProvider.suggest(new String[]{"true", "false"}, builder);
}
if (rule != null) {
return SharedSuggestionProvider.suggest(new String[]{String.valueOf(rule.defaultValue())}, builder);
}
return builder.buildFuture();
}
private static int setDimensionGameRule(
CommandSourceStack source,
MinecraftServer server,
String dimensionId,
String ruleId,
String value) {
ServerLevel level = findLevel(server, dimensionId);
if (level == null) {
source.sendFailure(Component.literal("§cDimension '" + dimensionId + "' tidak ditemukan atau tidak ter-load."));
return 0;
}
GameRule<?> rule = DimensionGameRuleManager.findRule(ruleId);
if (rule == null || !rule.isEnabled(level.enabledFeatures())) {
source.sendFailure(Component.literal("§cGamerule '" + ruleId + "' tidak ditemukan/tersedia di dimension ini."));
return 0;
}
if (!DimensionGameRuleManager.applySerialized(level, rule, value)) {
source.sendFailure(Component.literal("§cValue '" + value + "' tidak valid untuk gamerule " + rule.getIdentifierWithFallback() + "."));
return 0;
}
String normalizedDimension = level.dimension().identifier().toString();
String normalizedRule = rule.getIdentifierWithFallback().toString();
String actualValue = gameRuleValueUnchecked(level, rule);
if (!DIMENSION_GAMERULES.set(normalizedDimension, normalizedRule, actualValue)) {
source.sendFailure(Component.literal("§cGamerule sudah diterapkan di memory, tetapi gagal menyimpan dimension-gamerules.json."));
return 0;
}
sendCommandMessage(source, Component.literal(
"§aGamerule dimension disimpan. §7" + normalizedDimension
+ " §8| §f" + normalizedRule + " §7= §f" + actualValue));
LobbyLog.state(
"DIMENSION GAMERULE UPDATED",
"Dimension: " + normalizedDimension,
"Rule: " + normalizedRule,
"Value: " + actualValue,
"Persistent: yes"
);
return 1;
}
private static int resetDimensionGameRule(
CommandSourceStack source,
MinecraftServer server,
String dimensionId,
String ruleId) {
ServerLevel level = findLevel(server, dimensionId);
if (level == null) {
source.sendFailure(Component.literal("§cDimension '" + dimensionId + "' tidak ditemukan atau tidak ter-load."));
return 0;
}
GameRule<?> rule = DimensionGameRuleManager.findRule(ruleId);
if (rule == null || !rule.isEnabled(level.enabledFeatures())) {
source.sendFailure(Component.literal("§cGamerule '" + ruleId + "' tidak ditemukan/tersedia di dimension ini."));
return 0;
}
resetGameRuleUnchecked(level, rule);
String normalizedDimension = level.dimension().identifier().toString();
String normalizedRule = rule.getIdentifierWithFallback().toString();
boolean removed = DIMENSION_GAMERULES.remove(normalizedDimension, normalizedRule);
String actualValue = gameRuleValueUnchecked(level, rule);
sendCommandMessage(source, Component.literal(
"§aGamerule dimension di-reset ke default. §7" + normalizedDimension
+ " §8| §f" + normalizedRule + " §7= §f" + actualValue
+ (removed ? "" : " §8(config override sebelumnya tidak ada)")));
LobbyLog.state(
"DIMENSION GAMERULE RESET",
"Dimension: " + normalizedDimension,
"Rule: " + normalizedRule,
"Default: " + actualValue
);
return 1;
}
private static <T> void resetGameRuleUnchecked(ServerLevel level, GameRule<T> rule) {
DimensionGameRuleManager.resetToDefault(level, rule);
}
private static <T> String gameRuleValueUnchecked(ServerLevel level, GameRule<T> rule) {
return rule.serialize(level.getGameRules().get(rule));
}
private static int showDimensionGameMode(CommandSourceStack source, String dimensionId) {
String mode = DIMENSION_GAMEMODES.get(dimensionId);
if (mode == null) {
@@ -670,6 +845,10 @@ public final class LobbySystemsMod implements ModInitializer {
return 1;
}
static ServerLevel findLevelForManager(MinecraftServer server, String dimensionId) {
return findLevel(server, dimensionId);
}
private static ServerLevel findLevel(MinecraftServer server, String dimensionId) {
try {
Identifier id = Identifier.parse(dimensionId.trim());
@@ -0,0 +1,35 @@
package id.dartokloning.lobbysystems.mixin;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.gamerules.GameRules;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Vanilla Minecraft 26.2 stores gamerule values in one server-wide GameRuleMap.
* Lobby Systems gives every ServerLevel its own copy so rules can differ by
* dimension without leaking into other dimensions.
*/
@Mixin(ServerLevel.class)
public abstract class ServerLevelGameRulesMixin {
@Unique
private GameRules lobbySystems$dimensionGameRules;
@Inject(method = "getGameRules", at = @At("HEAD"), cancellable = true)
private void lobbySystems$getDimensionGameRules(CallbackInfoReturnable<GameRules> cir) {
if (lobbySystems$dimensionGameRules == null) {
ServerLevel self = (ServerLevel) (Object) this;
MinecraftServer server = self.getServer();
if (server != null) {
lobbySystems$dimensionGameRules = server.getGameRules().copy(self.enabledFeatures());
}
}
if (lobbySystems$dimensionGameRules != null) {
cir.setReturnValue(lobbySystems$dimensionGameRules);
}
}
}