This commit is contained in:
Darto KLoning
2026-10-03 20:36:02 +07:00
parent 73ca595a96
commit 6c9d3fb08f
8 changed files with 201 additions and 58 deletions
+42 -9
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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;
}
@@ -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<UUID, Session> sessions = new ConcurrentHashMap<>();
private final Map<String, PremiumCache> 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<String, Account> loaded = GSON.fromJson(r, new TypeToken<Map<String, Account>>(){}.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;
@@ -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() {}
}
@@ -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);
@@ -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<String> 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";
}
@@ -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)"}
}
}