diff --git a/README.md b/README.md index ce969da..562b443 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Lobby Systems v1.8.1 — Fabric 26.2 +# Lobby Systems v1.8.3 — Fabric 26.2 Server-side lobby framework untuk Minecraft Fabric 26.2. Lobby Systems menangani lobby placement, portal antar-dimensi, gamemode/gamerule per dimensi, Starter Kit, penyimpanan posisi player, dan hybrid authentication untuk premium + local/cracked player. @@ -44,10 +44,11 @@ config/lobby/ ├── starterkit-recipients.json └── auth/ ├── config.json + ├── rules.json └── accounts.json ``` -`/ls reload` me-reload **seluruh konfigurasi Lobby Systems**, termasuk config utama, Auth, portal, dimension gamemode, dimension gamerule, dan Starter Kit. Perubahan Auth juga diterapkan ke session player yang sedang online. Perubahan `rulesVersion`, `rulesEnabled`, `requireLoginEveryJoin`, dan `enabled` tidak memerlukan restart server. +`/ls reload` me-reload **seluruh konfigurasi Lobby Systems**, termasuk config utama, Auth, portal, dimension gamemode, dimension gamerule, dan Starter Kit. Perubahan Auth juga diterapkan ke session player yang sedang online. Perubahan Auth, termasuk `rules.json`, `requireLoginEveryJoin`, dan `enabled`, tidak memerlukan restart server. ## Lobby dan login placement @@ -108,7 +109,28 @@ Jadi `true` merupakan lapisan password tambahan untuk premium account, bukan pen ### Server Rules -Auth config menyediakan `rulesEnabled` dan `rulesVersion`. Acceptance disimpan per account. Naikkan `rulesVersion` jika semua account perlu menerima versi rules yang baru. `/ls reload` langsung menerapkan perubahan versi rules kepada player yang sedang online. +Rules dipisahkan dari Auth config dan dibuat otomatis sebagai `config/lobby/auth/rules.json`. File ini adalah sumber isi window Rules: + +```json +{ + "enabled": true, + "version": 1, + "title": "Server Rules", + "introduction": "Please read and accept our rules:", + "rules": [ + "No cheating, hacking, or exploits.", + "Be respectful to all players.", + "No advertising or spam.", + "Follow staff instructions.", + "Have fun and play fair!" + ], + "footer": "You must accept these rules to continue.", + "agreementLabel": "I have read and agree to the server rules", + "acceptButton": "I Accept" +} +``` + +Acceptance disimpan per account. `version` hanya perlu dinaikkan ketika perubahan rules memang harus diminta untuk diterima ulang oleh seluruh player. Mengedit teks tanpa menaikkan `version` tidak menghapus acceptance lama. `/ls reload` membaca ulang file ini dan menerapkan perubahan runtime. ### Auth account data @@ -187,7 +209,7 @@ Command `/ls` administratif memerlukan operator permission sesuai implementasi c Reload adalah satu pintu untuk seluruh runtime configuration. Urutannya mencakup: 1. `config.json` -2. Auth `config.json` + `accounts.json` +2. Auth `config.json` + `rules.json` + `accounts.json` 3. `portals.json` + portal visual repair 4. `dimension-gamemodes.json` + apply ke dimension/player 5. `dimension-gamerules.json` + apply ke loaded dimensions @@ -196,12 +218,15 @@ Reload adalah satu pintu untuk seluruh runtime configuration. Urutannya mencakup Console menampilkan `RELOAD START` dan `RELOAD COMPLETE`. Player admin yang menjalankan command menerima ringkasan hasil reload tanpa broadcast ke player lain. -## Catatan v1.8.1 +## Catatan v1.8.2 -- Memperbaiki compile error Fabric 26.2 pada Auth dialog command dispatch: `ServerPlayer#getServer()` diganti dengan server dari `player.level().getServer()`. -- `/ls reload` sekarang tidak hanya membaca ulang Auth config tetapi juga menerapkan perubahan Auth ke session player yang sedang online. -- README diatur ulang berdasarkan subsistem, bukan riwayat patch versi lama. -- Dialog Rules/Register/Login tetap menjadi primary authentication UI; command login/register dipertahankan sebagai fallback. +- Memperbaiki crash `ConcurrentModificationException` ketika authentication timeout men-disconnect player dari dalam server tick. Auth sekarang mengiterasi snapshot player list, bukan live `ArrayList`. +- Initial dialog ditunda singkat sampai player benar-benar tersedia sebagai target command; ini menghilangkan kegagalan awal `No player was found`. +- Dialog tidak lagi dibuka ulang setiap `reminderIntervalSeconds`, karena itu dapat menghapus password yang sedang diketik. +- Countdown authentication ditampilkan setiap detik melalui action bar. Setelah `authenticationTimeoutSeconds` habis, player otomatis disconnect. +- Rules dipindahkan ke `config/lobby/auth/rules.json` dan Rules dialog dibangun dari file tersebut. +- `rulesEnabled` dan `rulesVersion` lama dipindahkan menjadi `enabled` dan `version` di `rules.json`. +- `/ls reload` juga me-reload `rules.json` dan mengevaluasi ulang acceptance/session player online. ## Known compatibility note @@ -210,3 +235,11 @@ Local/cracked identity tidak memiliki Mojang profile public key. Jika server mem ## License All Rights Reserved. Source dan asset Lobby Systems tidak boleh didistribusikan atau digunakan ulang di luar izin pemilik project. + + +## Catatan v1.8.3 + +- Memperbaiki compile error Minecraft 26.2 karena `ServerPlayer.displayClientMessage(Component, boolean)` sudah tidak tersedia pada mapping/API target ini. +- Countdown authentication sekarang dikirim langsung dengan vanilla `ClientboundSetActionBarTextPacket`, sehingga tetap tampil sebagai action bar tanpa bergantung pada method player yang telah dihapus. +- Warna countdown menggunakan `ChatFormatting`/component styling, bukan legacy section-code di payload action bar. +- Seluruh perubahan v1.8.2 (safe timeout iteration, delayed initial dialog, `rules.json`, dan global `/ls reload`) tetap dipertahankan. diff --git a/gradle.properties b/gradle.properties index ed5c97f..446ede0 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.8.1 +mod_version=1.8.3 maven_group=id.dartokloning archives_base_name=lobby-systems diff --git a/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java b/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java index 6827349..7e72f8e 100644 --- a/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java +++ b/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java @@ -6,7 +6,6 @@ public final class AuthConfig { public boolean protectPremiumUsernames = true; public int authenticationTimeoutSeconds = 120; public int maxLoginAttempts = 3; - public int reminderIntervalSeconds = 8; public int minimumPasswordLength = 6; public int pbkdf2Iterations = 210000; public String mojangProfileUrl = "https://api.minecraftservices.com/minecraft/profile/lookup/name/{username}"; @@ -20,7 +19,4 @@ public final class AuthConfig { */ public boolean requireLoginEveryJoin = false; - /** Rules are accepted once per version. Increment to require acceptance again. */ - public boolean rulesEnabled = true; - public int rulesVersion = 1; } diff --git a/src/main/java/id/dartokloning/lobbysystems/AuthManager.java b/src/main/java/id/dartokloning/lobbysystems/AuthManager.java index d37011f..91719fb 100644 --- a/src/main/java/id/dartokloning/lobbysystems/AuthManager.java +++ b/src/main/java/id/dartokloning/lobbysystems/AuthManager.java @@ -4,6 +4,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.ClientboundSetActionBarTextPacket; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; @@ -23,6 +24,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; @@ -40,6 +42,7 @@ public final class AuthManager { private final Map sessions = new ConcurrentHashMap<>(); private final Map premiumCache = new ConcurrentHashMap<>(); private AuthConfig config = new AuthConfig(); + private RulesConfig rules = new RulesConfig(); public boolean load() { try { @@ -52,6 +55,14 @@ public final class AuthManager { } saveConfig(); // writes newly-added defaults/fields during upgrades + if (Files.exists(ConfigPaths.AUTH_RULES)) { + try (Reader r = Files.newBufferedReader(ConfigPaths.AUTH_RULES)) { + RulesConfig loadedRules = GSON.fromJson(r, RulesConfig.class); + if (loadedRules != null) rules = loadedRules; + } + } + saveRules(); // creates rules.json on first run and writes newly-added defaults + if (Files.exists(ConfigPaths.AUTH_ACCOUNTS)) { try (Reader r = Files.newBufferedReader(ConfigPaths.AUTH_ACCOUNTS)) { Map loaded = GSON.fromJson(r, new TypeToken>(){}.getType()); @@ -98,7 +109,7 @@ public final class AuthManager { s.stage = Stage.RULES; s.joinedAt = System.currentTimeMillis(); s.attempts = 0; - showDialog(player, DIALOG_RULES); + showRulesDialog(player); continue; } @@ -183,11 +194,10 @@ public final class AuthManager { if (needsRules(account)) { s.stage = Stage.RULES; - showDialog(player, DIALOG_RULES); - return; + } else { + prepareAfterRules(player); } - - continueAfterRules(player); + scheduleInitialDialog(s); } public void onDisconnect(UUID uuid) { sessions.remove(uuid); } @@ -210,10 +220,13 @@ public final class AuthManager { public void tick(MinecraftServer server) { if (!config.enabled) return; long now = System.currentTimeMillis(); - for (ServerPlayer player : server.getPlayerList().getPlayers()) { + // Use a snapshot: disconnect() can synchronously remove a player from PlayerList. + // Iterating the live ArrayList while doing that caused the v1.8.1 server crash. + for (ServerPlayer player : List.copyOf(server.getPlayerList().getPlayers())) { Session s = sessions.get(player.getUUID()); if (s == null || s.authenticated) continue; - if (now - s.joinedAt >= config.authenticationTimeoutSeconds * 1000L) { + long remainingMillis = config.authenticationTimeoutSeconds * 1000L - (now - s.joinedAt); + if (remainingMillis <= 0L) { player.connection.disconnect(Component.literal("Authentication timed out. Please reconnect and try again.")); continue; } @@ -222,10 +235,24 @@ public final class AuthManager { if (player.distanceToSqr(s.x, s.y, s.z) > 0.01) { player.connection.teleport(s.x, s.y, s.z, s.yaw, s.pitch); } - if (now - s.lastReminder >= config.reminderIntervalSeconds * 1000L) { - s.lastReminder = now; + + // JOIN can fire just before the player becomes targetable by /dialog. Defer the + // first dialog slightly instead of spamming a failing command immediately. + if (s.dialogPending && now >= s.dialogReadyAt) { + s.dialogPending = false; showCurrentDialog(player, s); } + + // A live countdown is shown in the action bar. Do not reopen the dialog every + // few seconds: reopening destroys text currently typed into password fields. + long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L); + if (remainingSeconds != s.lastCountdownSecond) { + s.lastCountdownSecond = remainingSeconds; + player.connection.send(new ClientboundSetActionBarTextPacket( + Component.literal("Authentication time remaining: " + remainingSeconds + "s") + .withStyle(net.minecraft.ChatFormatting.YELLOW) + )); + } } } @@ -235,7 +262,7 @@ public final class AuthManager { if (s == null || s.authenticated || s.stage != Stage.RULES) return 0; if (!accepted) { player.sendSystemMessage(Component.literal("§cYou must accept the server rules to continue.")); - showDialog(player, DIALOG_RULES); + showRulesDialog(player); return 0; } @@ -244,7 +271,7 @@ public final class AuthManager { Account a = accounts.computeIfAbsent(key(name), ignored -> Account.metadataOnly(s.premium)); a.accountType = s.premium ? "PREMIUM" : "LOCAL"; if (s.premium) a.officialUuid = player.getUUID().toString(); - a.rulesVersionAccepted = config.rulesVersion; + a.rulesVersionAccepted = rules.version; a.rulesAcceptedAt = Instant.now().toString(); saveAccounts(); } @@ -316,20 +343,28 @@ public final class AuthManager { private void continueAfterRules(ServerPlayer player) { Session s = sessions.get(player.getUUID()); if (s == null) return; + if (prepareAfterRules(player)) showCurrentDialog(player, s); + } + + /** Selects the post-rules stage. Returns true when a dialog is required. */ + private boolean prepareAfterRules(ServerPlayer player) { + Session s = sessions.get(player.getUUID()); + if (s == null) return false; if (s.premium && !config.requireLoginEveryJoin) { authenticate(player); player.sendSystemMessage(Component.literal("§aLobby Systems: Premium account verified. Auto-login successful.")); - return; + return false; } - if (isRegistered(player.getName().getString())) { - s.stage = Stage.LOGIN; - showDialog(player, DIALOG_LOGIN); - } else { - s.stage = Stage.REGISTER; - showDialog(player, DIALOG_REGISTER); - } + s.stage = isRegistered(player.getName().getString()) ? Stage.LOGIN : Stage.REGISTER; + return true; + } + + private void scheduleInitialDialog(Session s) { + if (s == null || s.authenticated) return; + s.dialogPending = true; + s.dialogReadyAt = System.currentTimeMillis() + 1000L; } private void authenticate(ServerPlayer player) { @@ -342,7 +377,7 @@ public final class AuthManager { } private boolean needsRules(Account account) { - return config.rulesEnabled && (account == null || account.rulesVersionAccepted < config.rulesVersion); + return rules.enabled && (account == null || account.rulesVersionAccepted < rules.version); } private void ensurePremiumMetadata(ServerPlayer player, Account existing) { @@ -361,13 +396,78 @@ public final class AuthManager { private void showCurrentDialog(ServerPlayer player, Session s) { switch (s.stage) { - case RULES -> showDialog(player, DIALOG_RULES); + case RULES -> showRulesDialog(player); case REGISTER -> showDialog(player, DIALOG_REGISTER); case LOGIN -> showDialog(player, DIALOG_LOGIN); default -> { } } } + private void showRulesDialog(ServerPlayer player) { + try { + com.google.gson.JsonObject dialog = new com.google.gson.JsonObject(); + dialog.addProperty("type", "minecraft:notice"); + dialog.add("title", textComponent(rules.title, "yellow", true)); + dialog.addProperty("can_close_with_escape", false); + dialog.addProperty("pause", false); + dialog.addProperty("after_action", "close"); + + com.google.gson.JsonArray body = new com.google.gson.JsonArray(); + body.add(plainMessage(rules.introduction, "yellow")); + StringBuilder lines = new StringBuilder(); + if (rules.rules != null) { + for (int i = 0; i < rules.rules.size(); i++) { + if (i > 0) lines.append("\n\n"); + lines.append(i + 1).append(". ").append(rules.rules.get(i)); + } + } + body.add(plainMessage(lines.toString(), "white")); + body.add(plainMessage(rules.footer + "\n\nRules version: " + rules.version, "gray")); + dialog.add("body", body); + + com.google.gson.JsonArray inputs = new com.google.gson.JsonArray(); + com.google.gson.JsonObject agreement = new com.google.gson.JsonObject(); + agreement.addProperty("type", "minecraft:boolean"); + agreement.addProperty("key", "accepted"); + agreement.add("label", textComponent(rules.agreementLabel, null, false)); + agreement.addProperty("initial", false); + agreement.addProperty("on_true", "true"); + agreement.addProperty("on_false", "false"); + inputs.add(agreement); + dialog.add("inputs", inputs); + + com.google.gson.JsonObject button = new com.google.gson.JsonObject(); + button.add("label", textComponent(rules.acceptButton, "green", false)); + com.google.gson.JsonObject action = new com.google.gson.JsonObject(); + action.addProperty("type", "dynamic/run_command"); + action.addProperty("template", "lobbyauth rules $(accepted)"); + button.add("action", action); + dialog.add("action", button); + + String command = "dialog show " + player.getGameProfile().name() + " " + GSON.toJson(dialog); + player.level().getServer().getCommands().getDispatcher().execute(command, player.level().getServer().createCommandSourceStack()); + LobbySystemsMod.LOGGER.info("Displayed rules dialog v{} to {}", rules.version, player.getName().getString()); + } catch (Exception e) { + LobbySystemsMod.LOGGER.error("Failed to show rules dialog v{} to {}", rules.version, player.getName().getString(), e); + player.sendSystemMessage(Component.literal("§cServer rules UI could not be opened. Please reconnect or contact staff.")); + } + } + + private static com.google.gson.JsonObject textComponent(String text, String color, boolean bold) { + com.google.gson.JsonObject o = new com.google.gson.JsonObject(); + o.addProperty("text", text == null ? "" : text); + if (color != null) o.addProperty("color", color); + if (bold) o.addProperty("bold", true); + return o; + } + + private static com.google.gson.JsonObject plainMessage(String text, String color) { + com.google.gson.JsonObject o = new com.google.gson.JsonObject(); + o.addProperty("type", "minecraft:plain_message"); + o.add("contents", textComponent(text, color, false)); + return o; + } + private void showDialog(ServerPlayer player, String id) { try { String command = "dialog show " + player.getGameProfile().name() + " " + id; @@ -448,6 +548,10 @@ public final class AuthManager { try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_CONFIG)) { GSON.toJson(config, w); } } + private void saveRules() throws Exception { + try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_RULES)) { GSON.toJson(rules, w); } + } + private void saveAccounts() { try { Files.createDirectories(ConfigPaths.AUTH_DIRECTORY); @@ -493,7 +597,10 @@ public final class AuthManager { final boolean premium; int attempts; long joinedAt; - long lastReminder; + long lastReminder; // retained for binary/source migration; no longer reopens dialogs + boolean dialogPending; + long dialogReadyAt; + long lastCountdownSecond = -1L; final double x, y, z; final float yaw, pitch; Stage stage = Stage.RULES; diff --git a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java index fccbee5..f70e385 100644 --- a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java +++ b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java @@ -16,6 +16,7 @@ public final class ConfigPaths { public static final Path AUTH_DIRECTORY = DIRECTORY.resolve("auth"); public static final Path AUTH_CONFIG = AUTH_DIRECTORY.resolve("config.json"); public static final Path AUTH_ACCOUNTS = AUTH_DIRECTORY.resolve("accounts.json"); + public static final Path AUTH_RULES = AUTH_DIRECTORY.resolve("rules.json"); private ConfigPaths() {} } diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java index dde6dfe..4850e8d 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java @@ -96,6 +96,7 @@ public final class LobbySystemsMod implements ModInitializer { authLoaded ? "AUTH SYSTEM READY" : "AUTH SYSTEM LOAD FAILED", "Config: " + ConfigPaths.AUTH_CONFIG, "Accounts: " + ConfigPaths.AUTH_ACCOUNTS, + "Rules: " + ConfigPaths.AUTH_RULES, "Enabled: " + AUTH.config().enabled, "Hybrid online-mode support: enabled" ); @@ -568,7 +569,10 @@ public final class LobbySystemsMod implements ModInitializer { LobbyLog.state( "RELOAD START", "Config: " + ConfigPaths.CONFIG, - "Portals: " + ConfigPaths.PORTALS + "Portals: " + ConfigPaths.PORTALS, + "Auth config: " + ConfigPaths.AUTH_CONFIG, + "Auth rules: " + ConfigPaths.AUTH_RULES, + "Auth accounts: " + ConfigPaths.AUTH_ACCOUNTS ); if (!CONFIG.load()) { @@ -592,8 +596,8 @@ public final class LobbySystemsMod implements ModInitializer { } if (!AUTH.load()) { - source.sendFailure(Component.literal("§eConfig utama direload, tetapi auth config/accounts bermasalah. Cek console.")); - LobbyLog.state("RELOAD FAILED", "Reason: auth config/accounts gagal dibaca"); + source.sendFailure(Component.literal("§eConfig utama direload, tetapi auth config/rules/accounts bermasalah. Cek console.")); + LobbyLog.state("RELOAD FAILED", "Reason: auth config/rules/accounts gagal dibaca"); return 0; } AUTH.applyReload(server); diff --git a/src/main/java/id/dartokloning/lobbysystems/RulesConfig.java b/src/main/java/id/dartokloning/lobbysystems/RulesConfig.java new file mode 100644 index 0000000..51bdd5a --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/RulesConfig.java @@ -0,0 +1,21 @@ +package id.dartokloning.lobbysystems; + +import java.util.ArrayList; +import java.util.List; + +public final class RulesConfig { + public boolean enabled = true; + public int version = 1; + public String title = "Server Rules"; + public String introduction = "Please read and accept our rules:"; + public List rules = new ArrayList<>(List.of( + "No cheating, hacking, or exploits.", + "Be respectful to all players.", + "No advertising or spam.", + "Follow staff instructions.", + "Have fun and play fair!" + )); + public String footer = "You must accept these rules to continue."; + public String agreementLabel = "I have read and agree to the server rules"; + public String acceptButton = "I Accept"; +} diff --git a/src/main/resources/data/dartokloning/dialog/auth_rules.json b/src/main/resources/data/dartokloning/dialog/auth_rules.json deleted file mode 100644 index a06243d..0000000 --- a/src/main/resources/data/dartokloning/dialog/auth_rules.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "minecraft:notice", - "title": {"text": "Server Rules", "color": "yellow", "bold": true}, - "can_close_with_escape": false, - "pause": false, - "after_action": "close", - "body": [ - {"type": "minecraft:plain_message", "contents": {"text": "Please read and accept our rules:", "color": "yellow"}}, - {"type": "minecraft:plain_message", "contents": {"text": "1. No cheating, hacking, or exploits\n\n2. Be respectful to all players\n\n3. No advertising or spam\n\n4. Follow staff instructions\n\n5. Have fun and play fair!", "color": "white"}}, - {"type": "minecraft:plain_message", "contents": {"text": "Click confirm to accept and continue.", "color": "gray"}} - ], - "inputs": [ - {"type": "minecraft:boolean", "key": "accepted", "label": {"text": "I have read and agree to the server rules"}, "initial": false, "on_true": "true", "on_false": "false"} - ], - "action": { - "label": {"text": "I Accept", "color": "green"}, - "action": {"type": "dynamic/run_command", "template": "lobbyauth rules $(accepted)"} - } -}