From ba4fc8853883390c06d574f71326185df85ac8f6 Mon Sep 17 00:00:00 2001 From: Darto KLoning Date: Sun, 6 Sep 2026 19:31:59 +0700 Subject: [PATCH] Add gamerule per dimension --- .gitattributes | 10 + README.md | 48 +++-- compile.bat | 63 +++++- compile.sh | 72 +++++-- gradle.properties | 2 +- .../lobbysystems/ConfigPaths.java | 1 + .../DimensionGameRuleManager.java | 201 ++++++++++++++++++ .../lobbysystems/LobbySystemsMod.java | 179 ++++++++++++++++ .../mixin/ServerLevelGameRulesMixin.java | 35 +++ src/main/resources/fabric.mod.json | 16 +- src/main/resources/lobby-systems.mixins.json | 11 + 11 files changed, 598 insertions(+), 40 deletions(-) create mode 100644 .gitattributes create mode 100644 src/main/java/id/dartokloning/lobbysystems/DimensionGameRuleManager.java create mode 100644 src/main/java/id/dartokloning/lobbysystems/mixin/ServerLevelGameRulesMixin.java create mode 100644 src/main/resources/lobby-systems.mixins.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b71af9f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize line endings across Windows, Linux, and macOS +* text=auto + +*.bat text eol=crlf +*.sh text eol=lf +*.java text eol=lf +*.json text eol=lf +*.md text eol=lf +*.gradle text eol=lf +*.properties text eol=lf diff --git a/README.md b/README.md index 90c5799..0066a16 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Lobby Systems v1.3.3 — Fabric 26.2 +# Lobby Systems v1.4.0 — Fabric 26.2 -Server-side lobby routing untuk Minecraft 26.2/Fabric. Versi 1.3.0 menambahkan viewer gamerule per-dimension dengan pagination dan pengelolaan gamemode player berdasarkan dimension, sambil mempertahankan portal antar-dimensi, last-position, minimum playtime, dan konfigurasi terpusat di `config/lobby/`. +Server-side lobby routing untuk Minecraft 26.2/Fabric. v1.4.0 menambahkan **native persistent per-dimension gamerules** milik Lobby Systems, tetap mempertahankan portal antar-dimensi, last-position, minimum playtime, gamerule viewer ber-pagination, serta per-dimension player gamemode. -**v1.3.3 bugfix:** viewer gamerule sekarang membaca nilai gamerule Minecraft 26.x secara aman per-rule, melewati rule yang tidak aktif/tidak tersedia untuk feature set dimension, dan tidak lagi menggagalkan seluruh command ketika satu rule bermasalah. Versi ini juga mengganti lisensi project menjadi proprietary **All Rights Reserved** dengan penggunaan hanya berdasarkan izin tertulis dari DartoKloning. +**v1.4.0 feature release:** gamerule tidak lagi bergantung pada WorldGameRules. Lobby Systems membuat instance `GameRules` terpisah untuk setiap `ServerLevel`, menyimpan override ke `config/lobby/dimension-gamerules.json`, lalu menerapkannya kembali saat world load, server start, dan `/ls reload`. WorldGameRules harus dihapus karena kedua mod mengubah mekanisme gamerule per-world. ### Reload feedback @@ -40,7 +40,7 @@ chmod +x compile.sh release.sh ```text ============================================ Lobby Systems - Fabric 26.2 Build - Version: 1.3.3 + Version: 1.4.0 Namespace: id.dartokloning.lobbysystems Mod ID: dartokloning ============================================ @@ -49,7 +49,7 @@ chmod +x compile.sh release.sh Output utama: ```text -build/libs/lobby-systems-1.3.3.jar +build/libs/lobby-systems-1.4.0.jar ``` `*-sources.jar` hanya source archive dan tidak perlu dipasang ke server. @@ -80,14 +80,14 @@ State penting Lobby Systems memakai separator agar mudah terlihat di server cons ```text ============================================ Lobby Systems - READY - Version: 1.3.3 + Version: 1.4.0 Minecraft: 26.2 Config directory: config/lobby WorldEdit: available ============================================ ``` -## Struktur konfigurasi v1.3.3 +## Struktur konfigurasi v1.4.0 ```text config/ @@ -95,7 +95,8 @@ config/ ├── config.json ├── player-positions.json ├── portals.json - └── dimension-gamemodes.json + ├── dimension-gamemodes.json + └── dimension-gamerules.json ``` ### `config/lobby/config.json` @@ -157,7 +158,7 @@ Portal adalah region/cuboid, bukan harus Nether Portal asli. Anda bebas membuat ### `config/lobby/dimension-gamemodes.json` -Minecraft menyimpan gamemode pada player, bukan pada dimension. Lobby Systems v1.3.3 menambahkan mapping dimension → gamemode dan menerapkannya ketika player masuk ke dimension tersebut. File ini dibuat otomatis dan dapat dikelola lewat command admin. +Minecraft menyimpan gamemode pada player, bukan pada dimension. Lobby Systems v1.4.0 menambahkan mapping dimension → gamemode dan menerapkannya ketika player masuk ke dimension tersebut. File ini dibuat otomatis dan dapat dikelola lewat command admin. Contoh: @@ -173,7 +174,7 @@ Gamemode yang didukung: `survival`, `creative`, `adventure`, dan `spectator`. ## Migrasi dari v1.1.x -Pada startup pertama v1.3.3: +Pada startup pertama v1.4.0: ```text config/lobby-systems.json @@ -197,7 +198,7 @@ akan dimigrasikan ke: config/lobby/player-positions.json ``` -Nama file lama hanya digunakan oleh `LegacyConfigMigrator` untuk proses migrasi. Runtime v1.3.3 membaca dan menulis file baru di `config/lobby/`. +Nama file lama hanya digunakan oleh `LegacyConfigMigrator` untuk proses migrasi. Runtime v1.4.0 membaca dan menulis file baru di `config/lobby/`. Jika file tujuan baru sudah ada, migrator tidak menimpanya. @@ -220,6 +221,8 @@ Jika file tujuan baru sudah ada, migrator tidak menimpanya. /ls gamerule /ls gamerule --page +/ls gamerule set +/ls gamerule reset /ls gamemode /ls gamemode list @@ -236,9 +239,27 @@ Jika file tujuan baru sudah ada, migrator tidak menimpanya. `/ls tp` tetap command maintenance/admin. Player biasa tidak memerlukannya karena flow normal menggunakan portal. -### Gamerule viewer per dimension +### Gamerule per dimension -`/ls gamerule ` menampilkan gamerule aktual dari dimension yang sedang ter-load. Viewer ini bersifat read-only dan cocok dipakai bersama WorldGameRules. Hasil ditampilkan 10 rule per halaman. +`/ls gamerule ` menampilkan gamerule aktual dari dimension yang sedang ter-load. Hasil ditampilkan 10 rule per halaman dan dipisahkan separator agar lebih mudah dibaca di chat. + +Untuk perubahan yang harus **persist setelah restart**, gunakan `/ls gamerule set ...`. Perintah vanilla `/gamerule` dapat mengubah nilai runtime yang terlihat pada dimension aktif, tetapi perubahan tersebut tidak ditulis ke `dimension-gamerules.json`, sehingga bukan source of truth Lobby Systems. + +> **Catatan Minecraft 26.2:** mayoritas gamerule gameplay server-side dapat diisolasi melalui `ServerLevel#getGameRules()`. Rule yang berhubungan dengan clock/weather seperti `minecraft:advance_time` dan `minecraft:advance_weather` memiliki mekanisme sinkronisasi tambahan di 26.x; uji keduanya secara khusus sebelum mengandalkannya untuk world yang membutuhkan waktu/cuaca benar-benar independen. + +Gunakan command berikut untuk membuat override persistent: + +```text +/ls gamerule set minecraft:lobby minecraft:spawn_mobs false +/ls gamerule set minecraft:lobby minecraft:mob_griefing false +/ls gamerule set minecraft:lobby minecraft:keep_inventory true +``` + +Untuk menghapus override dan mengembalikan rule ke default Minecraft: + +```text +/ls gamerule reset minecraft:lobby minecraft:spawn_mobs +``` Contoh: @@ -268,6 +289,7 @@ Reload live: - `config/lobby/config.json` - `config/lobby/portals.json` - `config/lobby/dimension-gamemodes.json` +- `config/lobby/dimension-gamerules.json` - lobby exclusion pada player-position store Tidak perlu restart untuk perubahan JSON tersebut. diff --git a/compile.bat b/compile.bat index daeb8f0..dc20f49 100644 --- a/compile.bat +++ b/compile.bat @@ -179,9 +179,12 @@ if defined HAS_CHANGES ( git status --short echo. + call :suggest_commit_message + echo [SUGGEST] Commit message: !SUGGESTED_COMMIT! + echo [INFO] Tekan Enter untuk memakai saran tersebut, atau ketik pesan sendiri. set "COMMIT_MESSAGE=" - set /p "COMMIT_MESSAGE=Commit message [Lobby Systems v%MOD_VERSION% build]: " - if not defined COMMIT_MESSAGE set "COMMIT_MESSAGE=Lobby Systems v%MOD_VERSION% build" + set /p "COMMIT_MESSAGE=Commit message [!SUGGESTED_COMMIT!]: " + if not defined COMMIT_MESSAGE set "COMMIT_MESSAGE=!SUGGESTED_COMMIT!" echo. echo [INFO] Menambahkan perubahan ke staging... @@ -217,18 +220,57 @@ if errorlevel 1 ( goto :git_failed ) -echo [INFO] Push ke origin/!CURRENT_BRANCH!... -git push origin "!CURRENT_BRANCH!" -if errorlevel 1 ( - echo [ERROR] Git push gagal. - echo [INFO] Commit lokal tetap tersimpan. - goto :git_failed -) +call :push_branch_with_retry +if errorlevel 1 goto :git_failed echo. echo [SUCCESS] Push ke Git/Gitea berhasil. goto :success +:suggest_commit_message +set "SUGGESTED_COMMIT=Update Lobby Systems v%MOD_VERSION%" +set "HAS_SRC_CHANGE=" +set "HAS_SCRIPT_CHANGE=" +set "HAS_DOC_CHANGE=" +for /f "delims=" %%F in ('git status --porcelain') do ( + set "LINE=%%F" + echo !LINE! | findstr /I /C:"src/" >nul && set "HAS_SRC_CHANGE=1" + echo !LINE! | findstr /I /C:"compile.bat" /C:"compile.sh" /C:"release.bat" /C:"release.sh" >nul && set "HAS_SCRIPT_CHANGE=1" + echo !LINE! | findstr /I /C:"README.md" /C:"LICENSE" >nul && set "HAS_DOC_CHANGE=1" +) +if defined HAS_SRC_CHANGE set "SUGGESTED_COMMIT=Update Lobby Systems v%MOD_VERSION%" +if not defined HAS_SRC_CHANGE if defined HAS_SCRIPT_CHANGE set "SUGGESTED_COMMIT=Improve build and Git workflow" +if not defined HAS_SRC_CHANGE if not defined HAS_SCRIPT_CHANGE if defined HAS_DOC_CHANGE set "SUGGESTED_COMMIT=Update Lobby Systems documentation" +exit /b 0 + +:push_branch_with_retry +set /a PUSH_ROUND=1 +:push_round_start +set /a PUSH_ATTEMPT=1 +:push_attempt_start +echo. +echo [INFO] Push attempt !PUSH_ATTEMPT!/3 ^(round !PUSH_ROUND!^) ke origin/!CURRENT_BRANCH!... +git push origin "!CURRENT_BRANCH!" +if not errorlevel 1 exit /b 0 + +echo [WARN] Push attempt !PUSH_ATTEMPT!/3 gagal. +if !PUSH_ATTEMPT! LSS 3 ( + set /a PUSH_ATTEMPT+=1 + echo [INFO] Mencoba lagi dalam 2 detik... + timeout /t 2 /nobreak >nul + goto :push_attempt_start +) + +echo. +echo ============================================ +echo GIT PUSH FAILED - 3 ATTEMPTS +echo ============================================ +echo [INFO] Commit lokal tetap aman dan tidak perlu compile ulang. +choice /C YN /N /M "Coba push 3x lagi? [Y/N]: " +if errorlevel 2 exit /b 1 +set /a PUSH_ROUND+=1 +goto :push_round_start + :skip_git echo. echo [INFO] Git commit/push dilewati. @@ -263,7 +305,8 @@ echo GIT FAILED echo ============================================ echo. echo [INFO] Build Lobby Systems tetap berhasil. -echo [INFO] Hanya proses Git yang mengalami kegagalan. +echo [INFO] Commit lokal tetap tersimpan. +echo [INFO] Anda bisa menjalankan compile.bat lagi dan memilih Y; jika tidak ada perubahan, script langsung mencoba push commit yang tertunda. echo. pause exit /b 2 diff --git a/compile.sh b/compile.sh index b711945..fa5a676 100644 --- a/compile.sh +++ b/compile.sh @@ -30,6 +30,54 @@ read_property() { grep -E "^${key}=" "$GRADLE_PROPERTIES" | head -n 1 | cut -d'=' -f2- | tr -d '\r' } +suggest_commit_message() { + local status + status="$(git status --porcelain)" + SUGGESTED_COMMIT="Update Lobby Systems v$MOD_VERSION" + + if grep -qiE '(^|[[:space:]])src/' <<<"$status"; then + SUGGESTED_COMMIT="Update Lobby Systems v$MOD_VERSION" + elif grep -qiE 'compile\.(bat|sh)|release\.(bat|sh)' <<<"$status"; then + SUGGESTED_COMMIT="Improve build and Git workflow" + elif grep -qiE 'README\.md|LICENSE' <<<"$status"; then + SUGGESTED_COMMIT="Update Lobby Systems documentation" + fi +} + +push_branch_with_retry() { + local round=1 + local attempt + + while true; do + attempt=1 + while [[ $attempt -le 3 ]]; do + echo + info "Push attempt $attempt/3 (round $round) ke origin/$CURRENT_BRANCH..." + if git push origin "$CURRENT_BRANCH"; then + return 0 + fi + + warn "Push attempt $attempt/3 gagal." + if [[ $attempt -lt 3 ]]; then + info "Mencoba lagi dalam 2 detik..." + sleep 2 + fi + attempt=$((attempt + 1)) + done + + echo + separator + echo " GIT PUSH FAILED - 3 ATTEMPTS" + separator + info "Commit lokal tetap aman dan tidak perlu compile ulang." + read -r -p "Coba push 3x lagi? [Y/N]: " RETRY_CONFIRM + case "$RETRY_CONFIRM" in + y|Y|yes|YES|Yes) round=$((round + 1)) ;; + *) return 1 ;; + esac + done +} + if [[ ! -f "$GRADLE_PROPERTIES" ]]; then error "gradle.properties tidak ditemukan." exit 1 @@ -122,7 +170,6 @@ if [[ ! -x "$GRADLE_BIN" ]]; then fi ok "Gradle $GRADLE_VERSION tersedia." - info "Menjalankan clean build Lobby Systems v$MOD_VERSION..." echo @@ -155,14 +202,12 @@ else echo "$JAR_PATH" fi -# release.sh memakai mode ini supaya build tidak menampilkan prompt Git dua kali. if [[ $BUILD_ONLY -eq 1 ]]; then exit 0 fi echo read -r -p "Commit dan push perubahan ke Git/Gitea? [Y/N]: " GIT_CONFIRM - case "$GIT_CONFIRM" in y|Y|yes|YES|Yes) ;; *) @@ -208,12 +253,11 @@ if [[ -n "$CHANGES" ]]; then git status --short echo - read -r -p "Commit message [Lobby Systems v$MOD_VERSION build]: " COMMIT_MESSAGE - - if [[ -z "$COMMIT_MESSAGE" ]]; then - TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')" - COMMIT_MESSAGE="Lobby Systems v$MOD_VERSION build - $TIMESTAMP" - fi + suggest_commit_message + echo "[SUGGEST] Commit message: $SUGGESTED_COMMIT" + info "Tekan Enter untuk memakai saran tersebut, atau ketik pesan sendiri." + read -r -p "Commit message [$SUGGESTED_COMMIT]: " COMMIT_MESSAGE + [[ -z "$COMMIT_MESSAGE" ]] && COMMIT_MESSAGE="$SUGGESTED_COMMIT" info "Menambahkan perubahan ke staging..." if ! git add -A; then @@ -232,7 +276,6 @@ else fi CURRENT_BRANCH="$(git branch --show-current)" - if [[ -z "$CURRENT_BRANCH" ]]; then error "Tidak dapat menentukan branch Git aktif." exit 2 @@ -252,12 +295,15 @@ fi REMOTE_URL="$(git remote get-url origin)" info "Remote origin: $REMOTE_URL" -info "Push ke origin/$CURRENT_BRANCH..." -if ! git push origin "$CURRENT_BRANCH"; then +if ! push_branch_with_retry; then echo - error "Git push gagal." + separator + echo " GIT FAILED" + separator + info "Build Lobby Systems tetap berhasil." info "Commit lokal tetap tersimpan." + info "Jalankan compile.sh lagi dan pilih Y; bila tidak ada perubahan, script langsung mencoba push commit yang tertunda." exit 2 fi diff --git a/gradle.properties b/gradle.properties index abefead..53cbb36 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,6 +5,6 @@ minecraft_version=26.2 loader_version=0.19.3 loom_version=1.17-SNAPSHOT fabric_api_version=0.156.0+26.2 -mod_version=1.3.3 +mod_version=1.4.0 maven_group=id.dartokloning archives_base_name=lobby-systems diff --git a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java index e3f5e0f..698ea2e 100644 --- a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java +++ b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java @@ -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() {} } diff --git a/src/main/java/id/dartokloning/lobbysystems/DimensionGameRuleManager.java b/src/main/java/id/dartokloning/lobbysystems/DimensionGameRuleManager.java new file mode 100644 index 0000000..3197ee2 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/DimensionGameRuleManager.java @@ -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> 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 dimensionEntry : rootElement.getAsJsonObject().entrySet()) { + if (!dimensionEntry.getValue().isJsonObject()) { + LobbySystemsMod.LOGGER.warn("Ignoring gamerule config for '{}': expected an object", dimensionEntry.getKey()); + continue; + } + + Map rules = new LinkedHashMap<>(); + for (Map.Entry 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> all() { + Map> 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 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> 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 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 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 boolean applySerializedTyped(ServerLevel level, GameRule 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 void resetToDefault(ServerLevel level, GameRule rule) { + level.getGameRules().set(rule, rule.defaultValue(), level.getServer()); + } + + public record ApplySummary(int dimensions, int applied, int skipped) {} +} diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java index ab8027d..733c993 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java @@ -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 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 void resetGameRuleUnchecked(ServerLevel level, GameRule rule) { + DimensionGameRuleManager.resetToDefault(level, rule); + } + + private static String gameRuleValueUnchecked(ServerLevel level, GameRule 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()); diff --git a/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLevelGameRulesMixin.java b/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLevelGameRulesMixin.java new file mode 100644 index 0000000..db54807 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLevelGameRulesMixin.java @@ -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 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); + } + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index e2a5ab2..e74008e 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,12 +3,16 @@ "id": "dartokloning", "version": "${version}", "name": "Lobby Systems", - "description": "Server-side Fabric lobby routing, persistent world positions, minimum playtime, portals, gamerule inspection, and per-dimension player gamemodes.", - "authors": ["DartoKloning"], + "description": "Server-side Fabric lobby routing, persistent world positions, portals, per-dimension gamemodes, and native persistent per-dimension gamerules.", + "authors": [ + "DartoKloning" + ], "license": "All Rights Reserved", "environment": "server", "entrypoints": { - "main": ["id.dartokloning.lobbysystems.LobbySystemsMod"] + "main": [ + "id.dartokloning.lobbysystems.LobbySystemsMod" + ] }, "depends": { "fabricloader": ">=0.19.3", @@ -18,5 +22,11 @@ }, "suggests": { "worldedit": "*" + }, + "mixins": [ + "lobby-systems.mixins.json" + ], + "breaks": { + "world-gamerules": "*" } } diff --git a/src/main/resources/lobby-systems.mixins.json b/src/main/resources/lobby-systems.mixins.json new file mode 100644 index 0000000..0395f89 --- /dev/null +++ b/src/main/resources/lobby-systems.mixins.json @@ -0,0 +1,11 @@ +{ + "required": true, + "package": "id.dartokloning.lobbysystems.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "ServerLevelGameRulesMixin" + ], + "injectors": { + "defaultRequire": 1 + } +}