Initial Lobby Systems v1.2.0

This commit is contained in:
Darto KLoning
2026-09-06 13:43:16 +07:00
commit 8ae4e6982d
16 changed files with 1671 additions and 0 deletions
@@ -0,0 +1,63 @@
package id.dartokloning.lobbysystems;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
public final class ConfigManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("lobby-systems.json");
private LobbyConfig config = new LobbyConfig();
public LobbyConfig get() {
return config;
}
public boolean load() {
if (!Files.exists(PATH)) {
save();
return true;
}
try (Reader reader = Files.newBufferedReader(PATH)) {
LobbyConfig loaded = GSON.fromJson(reader, LobbyConfig.class);
if (loaded == null) throw new IllegalArgumentException("Config JSON kosong atau tidak valid.");
validate(loaded);
config = loaded;
return true;
} catch (Exception e) {
LobbySystemsMod.LOGGER.error("Failed to load {}. Keeping previous config.", PATH, e);
return false;
}
}
public void save() {
try {
Files.createDirectories(PATH.getParent());
try (Writer writer = Files.newBufferedWriter(PATH)) {
GSON.toJson(config, writer);
}
} catch (IOException e) {
LobbySystemsMod.LOGGER.error("Failed to save {}", PATH, e);
}
}
public Path path() {
return PATH;
}
private static void validate(LobbyConfig c) {
if (c.lobbyDimension == null || c.lobbyDimension.isBlank()) {
throw new IllegalArgumentException("lobbyDimension tidak boleh kosong.");
}
if (c.minimumPlaySeconds < 0) {
throw new IllegalArgumentException("minimumPlaySeconds tidak boleh negatif.");
}
}
}
@@ -0,0 +1,15 @@
package id.dartokloning.lobbysystems;
public final class LobbyConfig {
public String lobbyDimension = "minecraft:overworld";
public double lobbyX = 0.5;
public double lobbyY = 100.0;
public double lobbyZ = 0.5;
public float lobbyYaw = 0.0f;
public float lobbyPitch = 0.0f;
public int minimumPlaySeconds = 300;
public String inLobbyMessage = "§eKamu sudah berada di lobby.";
public String returnedMessage = "§aKamu telah kembali ke lobby.";
public String waitMessage = "§cKamu belum bisa kembali ke lobby. Tunggu §e{time}§c lagi.";
}
@@ -0,0 +1,265 @@
package id.dartokloning.lobbysystems;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.logging.LogUtils;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.commands.Commands;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import org.slf4j.Logger;
public final class LobbySystemsMod implements ModInitializer {
public static final String MOD_ID = "dartokloning";
public static final Logger LOGGER = LogUtils.getLogger();
private static final ConfigManager CONFIG = new ConfigManager();
private static final SessionTracker SESSIONS = new SessionTracker();
private static final PositionManager POSITIONS = new PositionManager();
private static final TeleportCoordinator TELEPORTS = new TeleportCoordinator();
private static int saveTicker;
@Override
public void onInitialize() {
CONFIG.load();
POSITIONS.load();
POSITIONS.setExcludedDimension(lobbyKey());
ServerPlayConnectionEvents.JOIN.register((listener, sender, server) -> {
ServerPlayer player = listener.getPlayer();
server.execute(() -> {
// Simpan posisi login terakhir jika player sebelumnya logout di game dimension.
// Kalau player login di lobby, PositionManager akan mengabaikannya secara mutlak.
POSITIONS.captureCurrent(player);
// Jangan cross-dimension teleport pada callback JOIN itu sendiri.
// Tunggu 20 tick supaya client sudah stabil di play state.
TELEPORTS.scheduleJoinToLobby(player);
LOGGER.info("Scheduled lobby login teleport for {} ({})", player.getName().getString(), player.getUUID());
});
});
ServerPlayConnectionEvents.DISCONNECT.register((listener, server) -> {
ServerPlayer player = listener.getPlayer();
POSITIONS.onDisconnect(player);
SESSIONS.onDisconnect(player.getUUID());
TELEPORTS.onDisconnect(player.getUUID());
});
ServerTickEvents.END_SERVER_TICK.register(server -> {
TELEPORTS.tick(server, (player, minecraftServer) -> {
if (teleportToLobby(player, minecraftServer, false)) {
SESSIONS.onJoin(player.getUUID(), lobbyKey());
}
});
ResourceKey<Level> lobby = lobbyKey();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
POSITIONS.update(player);
SESSIONS.update(player.getUUID(), player.level().dimension(), lobby);
}
if (++saveTicker >= 1200) {
saveTicker = 0;
POSITIONS.saveIfDirty();
}
});
CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> {
dispatcher.register(Commands.literal("lobby").executes(ctx -> {
ServerPlayer player = ctx.getSource().getPlayerOrException();
ResourceKey<Level> lobby = lobbyKey();
if (player.level().dimension().equals(lobby)) {
player.sendSystemMessage(Component.literal(CONFIG.get().inLobbyMessage));
return 1;
}
if (TELEPORTS.isLocked(player)) {
player.sendSystemMessage(Component.literal("§eTeleport sebelumnya masih diproses. Tunggu sebentar lalu coba lagi."));
return 0;
}
long remaining = SESSIONS.remainingSeconds(player.getUUID(), CONFIG.get().minimumPlaySeconds);
if (remaining > 0) {
player.sendSystemMessage(Component.literal(
CONFIG.get().waitMessage.replace("{time}", formatDuration(remaining))));
return 0;
}
POSITIONS.captureCurrent(player);
return teleportToLobby(player, ctx.getSource().getServer(), true) ? 1 : 0;
}));
dispatcher.register(Commands.literal("ls")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("reload")
.executes(ctx -> reloadConfig(ctx.getSource().getServer(), ctx.getSource())))
.then(Commands.literal("tp")
.then(Commands.argument("dimension", StringArgumentType.greedyString())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
ctx.getSource().getServer().levelKeys().stream().map(ResourceKey::identifier), builder))
.executes(ctx -> teleportToDimension(
ctx.getSource().getPlayerOrException(),
ctx.getSource().getServer(),
StringArgumentType.getString(ctx, "dimension")
))))
);
});
LOGGER.info("Lobby Systems v1.1.3 initialized for Minecraft 26.2.");
}
private static int reloadConfig(MinecraftServer server, net.minecraft.commands.CommandSourceStack source) {
if (!CONFIG.load()) {
source.sendFailure(Component.literal("§cLobby Systems: reload gagal. Cek console untuk detail error JSON/config."));
return 0;
}
final ResourceKey<Level> key;
try {
key = lobbyKey();
if (server.getLevel(key) == null) {
source.sendFailure(Component.literal("§eConfig berhasil dibaca, tetapi lobbyDimension '" + CONFIG.get().lobbyDimension + "' tidak sedang ter-load."));
return 0;
}
} catch (Exception e) {
source.sendFailure(Component.literal("§cConfig berhasil dibaca, tetapi lobbyDimension tidak valid: " + CONFIG.get().lobbyDimension));
return 0;
}
// Ini bukan sekadar cleanup sekali. PositionManager menjadikan lobby sebagai
// excluded dimension permanen dan akan menyaringnya lagi sebelum setiap save.
POSITIONS.setExcludedDimension(key);
source.sendSuccess(() -> Component.literal(
"§aLobby Systems config direload. §7Lobby: §f" + CONFIG.get().lobbyDimension
+ " §7| Minimum play: §f" + CONFIG.get().minimumPlaySeconds + "s"), false);
return 1;
}
private static int teleportToDimension(ServerPlayer player, MinecraftServer server, String dimensionId) {
final Identifier id;
try {
id = Identifier.parse(dimensionId.trim());
} catch (Exception e) {
player.sendSystemMessage(Component.literal("§cDimension ID tidak valid: " + dimensionId));
return 0;
}
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
ServerLevel target = server.getLevel(key);
if (target == null) {
player.sendSystemMessage(Component.literal("§cDimension '" + dimensionId + "' tidak ditemukan atau tidak ter-load."));
return 0;
}
ResourceKey<Level> lobby = lobbyKey();
if (key.equals(lobby)) {
POSITIONS.captureCurrent(player);
boolean ok = teleportToLobby(player, server, false);
if (ok) {
player.sendSystemMessage(Component.literal("§aTeleport ke lobby pada posisi spawn yang dikonfigurasi."));
return 1;
}
return 0;
}
if (player.level().dimension().equals(key)) {
player.sendSystemMessage(Component.literal("§eKamu sudah berada di dimension " + dimensionId + "."));
return 1;
}
if (TELEPORTS.isLocked(player)) {
player.sendSystemMessage(Component.literal("§eTeleport sebelumnya masih diproses. Tunggu sebentar lalu coba lagi."));
return 0;
}
POSITIONS.captureCurrent(player);
PlayerPosition saved = POSITIONS.get(player.getUUID(), key);
double x;
double y;
double z;
float yaw;
float pitch;
boolean returning;
if (saved != null) {
x = saved.x;
y = saved.y;
z = saved.z;
yaw = saved.yaw;
pitch = saved.pitch;
returning = true;
} else {
BlockPos spawn = target.getRespawnData().pos();
x = spawn.getX() + 0.5;
y = spawn.getY();
z = spawn.getZ() + 0.5;
yaw = player.getYRot();
pitch = player.getXRot();
returning = false;
}
boolean ok = TELEPORTS.teleport(player, target, x, y, z, yaw, pitch);
if (!ok) {
player.sendSystemMessage(Component.literal("§cTeleport ke " + dimensionId + " gagal."));
return 0;
}
player.sendSystemMessage(Component.literal(returning
? "§aTeleport ke §f" + dimensionId + "§a pada posisi terakhirmu."
: "§aTeleport ke §f" + dimensionId + "§a. Belum ada posisi terakhir, jadi menggunakan spawn dimension."));
return 1;
}
private static ResourceKey<Level> lobbyKey() {
Identifier id = Identifier.parse(CONFIG.get().lobbyDimension);
return ResourceKey.create(Registries.DIMENSION, id);
}
private static boolean teleportToLobby(ServerPlayer player, MinecraftServer server, boolean sendMessage) {
final ResourceKey<Level> key;
try {
key = lobbyKey();
} catch (Exception e) {
player.sendSystemMessage(Component.literal("§cLobby Systems: lobbyDimension tidak valid."));
LOGGER.error("Invalid lobbyDimension: {}", CONFIG.get().lobbyDimension, e);
return false;
}
ServerLevel target = server.getLevel(key);
if (target == null) {
player.sendSystemMessage(Component.literal("§cLobby Systems: dimensi lobby '" + CONFIG.get().lobbyDimension + "' tidak ditemukan."));
LOGGER.error("Lobby dimension {} is not loaded/found.", CONFIG.get().lobbyDimension);
return false;
}
LobbyConfig c = CONFIG.get();
// Jika sudah di lobby (mis. join terakhir memang lobby), tidak perlu cross-dimension
// transition. Tetap pakai TeleportTransition untuk menempatkan player ke canonical spawn.
boolean ok = TELEPORTS.teleport(player, target, c.lobbyX, c.lobbyY, c.lobbyZ, c.lobbyYaw, c.lobbyPitch);
if (ok && sendMessage) {
player.sendSystemMessage(Component.literal(c.returnedMessage));
}
return ok;
}
private static String formatDuration(long seconds) {
long minutes = seconds / 60;
long secs = seconds % 60;
return minutes > 0 ? minutes + "m " + secs + "s" : secs + "s";
}
}
@@ -0,0 +1,20 @@
package id.dartokloning.lobbysystems;
public final class PlayerPosition {
public double x;
public double y;
public double z;
public float yaw;
public float pitch;
public PlayerPosition() {
}
public PlayerPosition(double x, double y, double z, float yaw, float pitch) {
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
}
}
@@ -0,0 +1,139 @@
package id.dartokloning.lobbysystems;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public final class PositionManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("lobby-systems-positions.json");
private static final Type DATA_TYPE = new TypeToken<Map<String, Map<String, PlayerPosition>>>() {}.getType();
private final Map<String, Map<String, PlayerPosition>> positions = new HashMap<>();
private final Map<UUID, Snapshot> snapshots = new HashMap<>();
private ResourceKey<Level> excludedDimension;
private boolean dirty;
public void load() {
positions.clear();
if (!Files.exists(PATH)) return;
try (Reader reader = Files.newBufferedReader(PATH)) {
Map<String, Map<String, PlayerPosition>> loaded = GSON.fromJson(reader, DATA_TYPE);
if (loaded != null) positions.putAll(loaded);
} catch (Exception e) {
LobbySystemsMod.LOGGER.error("Failed to load player positions from {}", PATH, e);
}
}
/**
* Dimension ini sama sekali tidak boleh disimpan sebagai last-position.
* Dipakai untuk lobby. Pemanggilan method ini juga membersihkan data lama
* yang mungkin masih tersisa dari versi mod sebelumnya.
*/
public void setExcludedDimension(ResourceKey<Level> dimension) {
this.excludedDimension = dimension;
purgeExcludedDimension();
saveIfDirty();
}
public void update(ServerPlayer player) {
UUID uuid = player.getUUID();
Snapshot current = Snapshot.from(player);
Snapshot previous = snapshots.put(uuid, current);
if (previous != null && !previous.dimension.equals(current.dimension)) {
store(uuid, previous.dimension, previous.position);
}
}
public void captureCurrent(ServerPlayer player) {
Snapshot snapshot = Snapshot.from(player);
snapshots.put(player.getUUID(), snapshot);
store(player.getUUID(), snapshot.dimension, snapshot.position);
}
public void onDisconnect(ServerPlayer player) {
captureCurrent(player);
snapshots.remove(player.getUUID());
saveIfDirty();
}
public PlayerPosition get(UUID uuid, ResourceKey<Level> dimension) {
if (isExcluded(dimension)) return null;
Map<String, PlayerPosition> byDimension = positions.get(uuid.toString());
if (byDimension == null) return null;
return byDimension.get(dimension.identifier().toString());
}
public void saveIfDirty() {
if (!dirty) return;
save();
}
public void save() {
// Guard terakhir: walaupun ada bug di caller, data lobby tidak boleh masuk file.
purgeExcludedDimension();
try {
Files.createDirectories(PATH.getParent());
try (Writer writer = Files.newBufferedWriter(PATH)) {
GSON.toJson(positions, DATA_TYPE, writer);
}
dirty = false;
} catch (Exception e) {
LobbySystemsMod.LOGGER.error("Failed to save player positions to {}", PATH, e);
}
}
private void store(UUID uuid, ResourceKey<Level> dimension, PlayerPosition position) {
if (isExcluded(dimension)) return;
positions.computeIfAbsent(uuid.toString(), ignored -> new HashMap<>())
.put(dimension.identifier().toString(), position);
dirty = true;
}
private boolean isExcluded(ResourceKey<Level> dimension) {
return excludedDimension != null && excludedDimension.equals(dimension);
}
private void purgeExcludedDimension() {
if (excludedDimension == null) return;
String dimensionId = excludedDimension.identifier().toString();
boolean changed = false;
for (Map<String, PlayerPosition> byDimension : positions.values()) {
if (byDimension.remove(dimensionId) != null) changed = true;
}
if (positions.entrySet().removeIf(entry -> entry.getValue().isEmpty())) changed = true;
if (changed) {
dirty = true;
LobbySystemsMod.LOGGER.info("Removed persisted lobby positions for dimension {}.", dimensionId);
}
}
private record Snapshot(ResourceKey<Level> dimension, PlayerPosition position) {
static Snapshot from(ServerPlayer player) {
return new Snapshot(
player.level().dimension(),
new PlayerPosition(player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot())
);
}
}
}
@@ -0,0 +1,40 @@
package id.dartokloning.lobbysystems;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public final class SessionTracker {
private final Map<UUID, ResourceKey<Level>> lastDimension = new HashMap<>();
private final Map<UUID, Long> gameEnteredAtMillis = new HashMap<>();
public void onJoin(UUID id, ResourceKey<Level> dimension) {
lastDimension.put(id, dimension);
gameEnteredAtMillis.remove(id);
}
public void onDisconnect(UUID id) {
lastDimension.remove(id);
gameEnteredAtMillis.remove(id);
}
public void update(UUID id, ResourceKey<Level> current, ResourceKey<Level> lobby) {
ResourceKey<Level> previous = lastDimension.put(id, current);
if (previous == null || previous.equals(current)) return;
if (current.equals(lobby)) {
gameEnteredAtMillis.remove(id);
} else {
gameEnteredAtMillis.put(id, System.currentTimeMillis());
}
}
public long remainingSeconds(UUID id, int minimumSeconds) {
Long enteredAt = gameEnteredAtMillis.get(id);
if (enteredAt == null) return minimumSeconds;
long elapsed = Math.max(0L, (System.currentTimeMillis() - enteredAt) / 1000L);
return Math.max(0L, minimumSeconds - elapsed);
}
}
@@ -0,0 +1,109 @@
package id.dartokloning.lobbysystems;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Relative;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Menangani teleport lintas dimensi melalui TeleportTransition vanilla.
* Juga memberi lock singkat agar request teleport tidak bertumpuk ketika
* client masih menyelesaikan pergantian dimension/chunk.
*/
public final class TeleportCoordinator {
private static final int TELEPORT_LOCK_TICKS = 40; // 2 detik
private static final int JOIN_DELAY_TICKS = 20; // 1 detik setelah JOIN
private final Map<UUID, Long> lockedUntilTick = new HashMap<>();
private final Map<UUID, PendingLobbyTeleport> pendingJoinTeleports = new HashMap<>();
private long currentTick;
public void scheduleJoinToLobby(ServerPlayer player) {
pendingJoinTeleports.put(player.getUUID(), new PendingLobbyTeleport(currentTick + JOIN_DELAY_TICKS));
}
public void onDisconnect(UUID uuid) {
lockedUntilTick.remove(uuid);
pendingJoinTeleports.remove(uuid);
}
public void tick(MinecraftServer server, LobbyTeleportHandler lobbyHandler) {
currentTick++;
lockedUntilTick.entrySet().removeIf(entry -> entry.getValue() <= currentTick);
pendingJoinTeleports.entrySet().removeIf(entry -> {
if (entry.getValue().executeAtTick > currentTick) return false;
ServerPlayer player = server.getPlayerList().getPlayer(entry.getKey());
if (player != null) {
lobbyHandler.teleport(player, server);
}
return true;
});
}
public boolean isLocked(ServerPlayer player) {
return lockedUntilTick.getOrDefault(player.getUUID(), 0L) > currentTick;
}
public boolean teleport(ServerPlayer player, ServerLevel target,
double x, double y, double z, float yaw, float pitch) {
if (isLocked(player)) {
player.sendSystemMessage(Component.literal("§eTeleport sebelumnya masih diproses. Tunggu sebentar lalu coba lagi."));
return false;
}
ResourceKey<Level> from = player.level().dimension();
ResourceKey<Level> to = target.dimension();
// Paksa destination chunk tersedia sebelum transition dikirim ke client.
int chunkX = ((int) Math.floor(x)) >> 4;
int chunkZ = ((int) Math.floor(z)) >> 4;
target.getChunk(chunkX, chunkZ);
lockedUntilTick.put(player.getUUID(), currentTick + TELEPORT_LOCK_TICKS);
TeleportTransition transition = new TeleportTransition(
target,
new Vec3(x, y, z),
Vec3.ZERO,
yaw,
pitch,
Set.<Relative>of(),
TeleportTransition.DO_NOTHING
);
LobbySystemsMod.LOGGER.info(
"Teleporting {} ({}): {} -> {} @ [{}, {}, {}]",
player.getName().getString(), player.getUUID(),
from.identifier(), to.identifier(),
String.format("%.2f", x), String.format("%.2f", y), String.format("%.2f", z)
);
ServerPlayer result = player.teleport(transition);
if (result == null) {
lockedUntilTick.remove(player.getUUID());
LobbySystemsMod.LOGGER.warn("Teleport failed for {}: {} -> {}", player.getUUID(), from.identifier(), to.identifier());
return false;
}
return true;
}
@FunctionalInterface
public interface LobbyTeleportHandler {
void teleport(ServerPlayer player, MinecraftServer server);
}
private record PendingLobbyTeleport(long executeAtTick) {}
}