diff --git a/README.md b/README.md index beb9b75..d21128e 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,280 @@ -# Lobby Systems - Fabric 26.2 +# Lobby Systems v1.2.0 — Fabric 26.2 -Server-side lobby router untuk Minecraft Java 26.2 / Fabric. +Server-side lobby routing untuk Minecraft 26.2/Fabric. Versi 1.2.0 menambahkan portal antar-dimensi berbasis WorldEdit selection, last-position per player/per dimension, minimum playtime sebelum kembali ke lobby, dan konfigurasi terpusat di `config/lobby/`. -## Identity +## Target build + +- Minecraft: 26.2 +- Fabric Loader: 0.19.3 +- Fabric API: 0.156.0+26.2 +- Fabric Loom: 1.17 +- Java: 25+ - Mod ID: `dartokloning` -- Java package: `id.dartokloning.lobbysystems` -- Maven group: `id.dartokloning` -- Mod name: `Lobby Systems` - -## Requirements -- JDK / Java 25 atau lebih baru -- Minecraft 26.2 -- Fabric Loader 0.19.3+ -- Fabric API 0.156.0+26.2 -- Internet pada build pertama untuk mengunduh Gradle dan dependency Fabric/Minecraft -- Optional: World Manager 1.4.4+26.2 hanya untuk membuat/mengelola world; Lobby Systems tidak memiliki hard dependency ke World Manager +- Java namespace: `id.dartokloning.lobbysystems` +- WorldEdit: opsional untuk runtime portal, diperlukan saat membuat portal memakai `//wand` ## Compile di Windows -Cara termudah: double-click `compile.bat`. -Script tersebut memanggil `compile.ps1`, mengecek Java 25+, mengunduh Gradle 9.5.1 secara lokal ke `.gradle-local` bila diperlukan, lalu menjalankan: - -```text -clean build -``` - -Alternatif PowerShell: +Double-click `compile.bat` atau jalankan: ```powershell -powershell -ExecutionPolicy Bypass -File .\compile.ps1 +.\compile.bat ``` -JAR hasil build berada di: +Atau langsung: + +```powershell +.\compile.ps1 +``` + +Kedua script membaca `mod_version` dan `minecraft_version` langsung dari `gradle.properties`. Header build akan menampilkan versi yang sedang di-compile, misalnya: ```text -build\libs\ +============================================ + Lobby Systems - Fabric 26.2 Build + Version: 1.2.0 + Namespace: id.dartokloning.lobbysystems + Mod ID: dartokloning +============================================ ``` -Folder `.gradle-local` hanya cache Gradle lokal project dan boleh dihapus kapan saja. +Output utama: -## Compile manual -Jika Gradle sudah terpasang: - -```bash -gradle clean build +```text +build/libs/lobby-systems-1.2.0.jar ``` -## Configuration -Jalankan server sekali. Edit: +`*-sources.jar` hanya source archive dan tidak perlu dipasang ke server. -`config/lobby-systems.json` +## Struktur konfigurasi v1.2.0 + +```text +config/ +└── lobby/ + ├── config.json + ├── player-positions.json + └── portals.json +``` + +### `config/lobby/config.json` Contoh: ```json { - "lobbyDimension": "minecraft:overworld", - "lobbyX": 0.5, - "lobbyY": 100.0, - "lobbyZ": 0.5, + "lobbyDimension": "minecraft:lobby", + "lobbyX": -12.5, + "lobbyY": 65.0, + "lobbyZ": -3.5, "lobbyYaw": 0.0, "lobbyPitch": 0.0, "minimumPlaySeconds": 300, + "portalCheckIntervalTicks": 5, + "portalCooldownTicks": 60, "inLobbyMessage": "§eKamu sudah berada di lobby.", "returnedMessage": "§aKamu telah kembali ke lobby.", "waitMessage": "§cKamu belum bisa kembali ke lobby. Tunggu §e{time}§c lagi." } ``` -Set `lobbyDimension` ke identifier dimensi/world yang dibuat oleh World Manager. +Lobby selalu memakai koordinat statis dari file ini. Lobby **tidak** disimpan sebagai last-position player. -## Behavior -1. Setiap login diarahkan ke dimensi dan koordinat lobby yang dikonfigurasi. -2. Saat player keluar lobby dan masuk dimensi lain, timer sesi game baru dimulai. -3. `/lobby` saat sudah berada di lobby hanya memberi informasi bahwa player sedang di lobby. -4. `/lobby` di luar lobby diblokir sampai `minimumPlaySeconds` berlalu dan menampilkan countdown. -5. Setelah timer selesai, `/lobby` mengembalikan player ke lobby. -6. Pindah dari satu dimensi non-lobby ke dimensi game lain memulai timer baru untuk dimensi tersebut. -7. Reconnect selalu mengembalikan player ke lobby dan membersihkan timer sesi sebelumnya. +### `config/lobby/player-positions.json` -## World Manager integration -Lobby Systems tidak menggunakan internal API World Manager. World Manager cukup membuat/load custom world, kemudian ID dimensinya dimasukkan ke `lobby-systems.json`. Dengan desain ini, portal mod/command lain juga tetap bisa digunakan. +Berisi posisi terakhir masing-masing player pada dimension non-lobby. Data ini dipakai ketika player kembali melalui portal atau `/ls tp`. -## Admin command (v1.1.3) +Dimension yang sedang dikonfigurasi sebagai lobby disaring pada saat capture, read, dan save sehingga tidak disimpan di file ini. -Requires Minecraft admin/OP command permission. +### `config/lobby/portals.json` -- `/ls reload` — reloads `config/lobby-systems.json` live without restarting the server. -- `/ls tp ` — teleports the executing player to a loaded dimension. The `` argument autocompletes from the server's currently loaded dimension keys. +Portal dibuat lewat command dan tersimpan otomatis. Contoh: -### Last-position behavior +```json +{ + "portals": { + "survival": { + "sourceDimension": "minecraft:lobby", + "min": { + "x": 10, + "y": 64, + "z": -5 + }, + "max": { + "x": 12, + "y": 67, + "z": -5 + }, + "targetDimension": "minecraft:overworld" + } + } +} +``` -Lobby Systems stores each player's last coordinates and rotation per dimension in: +Portal adalah region/cuboid, bukan harus Nether Portal asli. Anda bebas membuat bentuk doorway, arch, lantai, gateway, dan sebagainya di dalam region tersebut. -`config/lobby-systems-positions.json` +## Migrasi dari v1.1.x -When `/ls tp ` is used, the player returns to their last recorded position in that dimension. If the player has never visited that dimension before, the command falls back to the dimension's configured respawn/spawn position. +Pada startup pertama v1.2.0: -Positions are captured for non-lobby dimensions when players change dimensions, use `/ls tp`, use `/lobby`, disconnect, and are periodically flushed to disk. The configured lobby dimension is deliberately excluded: every teleport/login to the lobby always uses `lobbyX/Y/Z/Yaw/Pitch` from `lobby-systems.json`. Existing saved lobby-position entries are removed automatically on startup/reload. +```text +config/lobby-systems.json +``` -## v1.1.3 stability changes -- Cross-dimension teleports now use Minecraft's `TeleportTransition` path instead of calling `teleportTo(...)` directly. -- Destination chunk is loaded before the transition is sent. -- Login-to-lobby is delayed 20 server ticks so the client has time to finish entering play state. -- A 40-tick per-player teleport lock prevents overlapping dimension transitions. -- Lobby position persistence is enforced inside `PositionManager`, not only at call sites. The configured lobby dimension is purged on startup/reload and filtered again before every save, so it cannot be written to `lobby-systems-positions.json`. +akan dimigrasikan ke: + +```text +config/lobby/config.json +``` + +Dan: + +```text +config/lobby-systems-positions.json +``` + +akan dimigrasikan ke: + +```text +config/lobby/player-positions.json +``` + +Nama file lama hanya digunakan oleh `LegacyConfigMigrator` untuk proses migrasi. Runtime v1.2.0 membaca dan menulis file baru di `config/lobby/`. + +Jika file tujuan baru sudah ada, migrator tidak menimpanya. + +## Command player + +```text +/lobby +``` + +- Jika sudah di lobby: memberi informasi bahwa player sudah berada di lobby. +- Jika berada di gameplay dimension: hanya dapat digunakan setelah `minimumPlaySeconds` tercapai. +- Jika terlalu cepat: menampilkan countdown. +- Jika berhasil: selalu kembali ke fixed lobby spawn dari `config.json`. + +## Command admin / OP + +```text +/ls reload +/ls tp + +/ls portal create +/ls portal delete +/ls portal list +/ls portal info +/ls portal tp +``` + +`/ls tp` tetap command maintenance/admin. Player biasa tidak memerlukannya karena flow normal menggunakan portal. + +### `/ls reload` + +Reload live: + +- `config/lobby/config.json` +- `config/lobby/portals.json` +- lobby exclusion pada player-position store + +Tidak perlu restart untuk perubahan JSON tersebut. + +### Membuat portal dengan WorldEdit + +1. Pastikan WorldEdit Fabric 26.2 terpasang di server. +2. Masuk ke dimension tempat portal akan berada, biasanya Lobby. +3. Gunakan: + +```text +//wand +``` + +4. Select dua sudut cuboid portal. +5. Buat portal: + +```text +/ls portal create survival minecraft:overworld +``` + +Target dimension memiliki autocomplete dari daftar dimension yang sedang loaded. + +Contoh lain: + +```text +/ls portal create nether minecraft:the_nether +/ls portal create dungeon mypack:dungeon +``` + +Nama portal hanya menerima huruf kecil `a-z`, angka `0-9`, `_`, dan `-`. + +WorldEdit hanya dibutuhkan saat membaca selection untuk **membuat** portal. Setelah `portals.json` tersimpan, portal runtime dikelola Lobby Systems sendiri. Jika WorldEdit dilepas kemudian, portal yang sudah ada tetap dapat bekerja. + +### Menghapus dan mengecek portal + +```text +/ls portal list +/ls portal info survival +/ls portal delete survival +``` + +Nama portal memiliki autocomplete. + +### Teleport admin ke region portal + +```text +/ls portal tp survival +``` + +Command ini digunakan untuk maintenance. Lobby Systems memberi suppression sementara agar admin tidak langsung mengaktifkan portal tepat setelah teleport ke region tersebut. + +## Perilaku destination + +Jika target adalah gameplay dimension: + +- player pernah mengunjungi dimension tersebut → teleport ke last position; +- belum pernah → fallback ke spawn dimension. + +Jika target adalah Lobby: + +- selalu teleport ke `lobbyX/Y/Z/Yaw/Pitch`; +- tidak pernah menggunakan last-position; +- jika portal berasal dari gameplay dimension, minimum playtime tetap diberlakukan. + +## Portal detection dan performa + +`portals.json` tidak dibaca setiap tick. Portal dimuat ke RAM saat startup atau `/ls reload` dan di-index berdasarkan source dimension. + +Default pemeriksaan region: + +```json +"portalCheckIntervalTicks": 5 +``` + +Artinya sekitar empat kali per detik. Region check hanya berupa perbandingan koordinat player dengan portal pada dimension yang sama. + +Setelah portal aktif, `portalCooldownTicks` mencegah portal terpicu berulang saat client menyelesaikan dimension transition. Teleport lintas dimension tetap memakai `TeleportTransition` dan teleport lock dari v1.1.3. + +## Flow server + +```text +JOIN + ↓ +Lobby fixed spawn + ↓ +Player berjalan ke portal + ↓ +Portal region terdeteksi + ↓ +Gameplay dimension + ↓ +Last position jika tersedia / spawn jika pertama kali + ↓ +Minimum gameplay time + ↓ +/lobby + ↓ +Lobby fixed spawn +``` + +## Catatan pengujian + +Karena project ini menargetkan Java 25, compile final sebaiknya dilakukan menggunakan `compile.bat`/`compile.ps1` pada mesin dengan JDK 25. Saat menguji portal, perhatikan console untuk log teleport dan error WorldEdit selection jika selection belum lengkap. diff --git a/gradle.properties b/gradle.properties index 0dd64b4..e756702 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.1.3 +mod_version=1.2.0 maven_group=id.dartokloning archives_base_name=lobby-systems diff --git a/src/main/java/id/dartokloning/lobbysystems/ConfigManager.java b/src/main/java/id/dartokloning/lobbysystems/ConfigManager.java index fa7d038..73625ca 100644 --- a/src/main/java/id/dartokloning/lobbysystems/ConfigManager.java +++ b/src/main/java/id/dartokloning/lobbysystems/ConfigManager.java @@ -2,9 +2,7 @@ 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; @@ -12,7 +10,7 @@ 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 static final Path PATH = ConfigPaths.CONFIG; private LobbyConfig config = new LobbyConfig(); public LobbyConfig get() { @@ -43,7 +41,7 @@ public final class ConfigManager { try (Writer writer = Files.newBufferedWriter(PATH)) { GSON.toJson(config, writer); } - } catch (IOException e) { + } catch (Exception e) { LobbySystemsMod.LOGGER.error("Failed to save {}", PATH, e); } } @@ -59,5 +57,11 @@ public final class ConfigManager { if (c.minimumPlaySeconds < 0) { throw new IllegalArgumentException("minimumPlaySeconds tidak boleh negatif."); } + if (c.portalCheckIntervalTicks < 1) { + throw new IllegalArgumentException("portalCheckIntervalTicks minimal 1."); + } + if (c.portalCooldownTicks < 1) { + throw new IllegalArgumentException("portalCooldownTicks minimal 1."); + } } } diff --git a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java new file mode 100644 index 0000000..7302d3b --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java @@ -0,0 +1,14 @@ +package id.dartokloning.lobbysystems; + +import net.fabricmc.loader.api.FabricLoader; + +import java.nio.file.Path; + +public final class ConfigPaths { + public static final Path DIRECTORY = FabricLoader.getInstance().getConfigDir().resolve("lobby"); + public static final Path CONFIG = DIRECTORY.resolve("config.json"); + public static final Path PLAYER_POSITIONS = DIRECTORY.resolve("player-positions.json"); + public static final Path PORTALS = DIRECTORY.resolve("portals.json"); + + private ConfigPaths() {} +} diff --git a/src/main/java/id/dartokloning/lobbysystems/LegacyConfigMigrator.java b/src/main/java/id/dartokloning/lobbysystems/LegacyConfigMigrator.java new file mode 100644 index 0000000..88ce0a6 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/LegacyConfigMigrator.java @@ -0,0 +1,39 @@ +package id.dartokloning.lobbysystems; + +import net.fabricmc.loader.api.FabricLoader; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Satu-satunya tempat yang mengetahui nama file konfigurasi versi lama. + * Runtime v1.2.0 hanya membaca/menulis file di config/lobby/. + */ +public final class LegacyConfigMigrator { + private LegacyConfigMigrator() {} + + public static void migrate() { + Path oldConfig = FabricLoader.getInstance().getConfigDir().resolve("lobby-systems.json"); + Path oldPositions = FabricLoader.getInstance().getConfigDir().resolve("lobby-systems-positions.json"); + + migrateOne(oldConfig, ConfigPaths.CONFIG, "configuration"); + migrateOne(oldPositions, ConfigPaths.PLAYER_POSITIONS, "player positions"); + } + + private static void migrateOne(Path source, Path target, String label) { + if (!Files.exists(source) || Files.exists(target)) return; + try { + Files.createDirectories(target.getParent()); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + LobbySystemsMod.LOGGER.info("Migrated legacy {}: {} -> {}", label, source, target); + } catch (Exception moveError) { + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + LobbySystemsMod.LOGGER.info("Copied legacy {}: {} -> {}", label, source, target); + } catch (Exception copyError) { + LobbySystemsMod.LOGGER.error("Failed to migrate legacy {} from {} to {}", label, source, target, copyError); + } + } + } +} diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbyConfig.java b/src/main/java/id/dartokloning/lobbysystems/LobbyConfig.java index 872a9e1..cb3b976 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbyConfig.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbyConfig.java @@ -9,6 +9,11 @@ public final class LobbyConfig { public float lobbyPitch = 0.0f; public int minimumPlaySeconds = 300; + /** Seberapa sering region portal diperiksa. 5 tick = 4 kali/detik. */ + public int portalCheckIntervalTicks = 5; + /** Cooldown setelah portal terpicu. 60 tick = 3 detik. */ + public int portalCooldownTicks = 60; + 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."; diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java index 9f4fc3f..c3d9d48 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java @@ -6,6 +6,7 @@ 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.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.core.BlockPos; @@ -26,24 +27,22 @@ public final class LobbySystemsMod implements ModInitializer { private static final ConfigManager CONFIG = new ConfigManager(); private static final SessionTracker SESSIONS = new SessionTracker(); private static final PositionManager POSITIONS = new PositionManager(); + private static final PortalManager PORTALS = new PortalManager(); private static final TeleportCoordinator TELEPORTS = new TeleportCoordinator(); private static int saveTicker; @Override public void onInitialize() { + LegacyConfigMigrator.migrate(); CONFIG.load(); POSITIONS.load(); POSITIONS.setExcludedDimension(lobbyKey()); + PORTALS.load(); 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()); }); @@ -54,6 +53,7 @@ public final class LobbySystemsMod implements ModInitializer { POSITIONS.onDisconnect(player); SESSIONS.onDisconnect(player.getUUID()); TELEPORTS.onDisconnect(player.getUUID()); + PORTALS.onDisconnect(player.getUUID()); }); ServerTickEvents.END_SERVER_TICK.register(server -> { @@ -69,6 +69,10 @@ public final class LobbySystemsMod implements ModInitializer { SESSIONS.update(player.getUUID(), player.level().dimension(), lobby); } + LobbyConfig cfg = CONFIG.get(); + PORTALS.tick(server, cfg.portalCheckIntervalTicks, cfg.portalCooldownTicks, + (player, portalName, portal) -> activatePortal(player, server, portalName, portal)); + if (++saveTicker >= 1200) { saveTicker = 0; POSITIONS.saveIfDirty(); @@ -76,35 +80,13 @@ public final class LobbySystemsMod implements ModInitializer { }); CommandRegistrationCallback.EVENT.register((dispatcher, buildContext, selection) -> { - dispatcher.register(Commands.literal("lobby").executes(ctx -> { - ServerPlayer player = ctx.getSource().getPlayerOrException(); - ResourceKey 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("lobby").executes(ctx -> handleLobbyCommand( + ctx.getSource().getPlayerOrException(), ctx.getSource().getServer()))); dispatcher.register(Commands.literal("ls") .requires(Commands.hasPermission(Commands.LEVEL_ADMINS)) .then(Commands.literal("reload") - .executes(ctx -> reloadConfig(ctx.getSource().getServer(), ctx.getSource()))) + .executes(ctx -> reloadAll(ctx.getSource().getServer(), ctx.getSource()))) .then(Commands.literal("tp") .then(Commands.argument("dimension", StringArgumentType.greedyString()) .suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource( @@ -112,17 +94,68 @@ public final class LobbySystemsMod implements ModInitializer { .executes(ctx -> teleportToDimension( ctx.getSource().getPlayerOrException(), ctx.getSource().getServer(), - StringArgumentType.getString(ctx, "dimension") - )))) + StringArgumentType.getString(ctx, "dimension"), + true, + true)))) + .then(Commands.literal("portal") + .then(Commands.literal("create") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("target", StringArgumentType.greedyString()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource( + ctx.getSource().getServer().levelKeys().stream().map(ResourceKey::identifier), builder)) + .executes(ctx -> createPortal( + ctx.getSource().getPlayerOrException(), + ctx.getSource().getServer(), + StringArgumentType.getString(ctx, "name"), + StringArgumentType.getString(ctx, "target")))))) + .then(Commands.literal("delete") + .then(Commands.argument("name", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(PORTALS.names(), builder)) + .executes(ctx -> deletePortal(ctx.getSource(), StringArgumentType.getString(ctx, "name"))))) + .then(Commands.literal("list") + .executes(ctx -> listPortals(ctx.getSource()))) + .then(Commands.literal("info") + .then(Commands.argument("name", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(PORTALS.names(), builder)) + .executes(ctx -> portalInfo(ctx.getSource(), StringArgumentType.getString(ctx, "name"))))) + .then(Commands.literal("tp") + .then(Commands.argument("name", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(PORTALS.names(), builder)) + .executes(ctx -> teleportToPortal( + ctx.getSource().getPlayerOrException(), + ctx.getSource().getServer(), + StringArgumentType.getString(ctx, "name")))))) ); }); - LOGGER.info("Lobby Systems v1.1.3 initialized for Minecraft 26.2."); + LOGGER.info("Lobby Systems v1.2.0 initialized for Minecraft 26.2. Config directory: {}", ConfigPaths.DIRECTORY); + LOGGER.info("WorldEdit integration: {}", WorldEditSelectionBridge.isAvailable() ? "available" : "not installed (saved portals still work)"); } - private static int reloadConfig(MinecraftServer server, net.minecraft.commands.CommandSourceStack source) { + private static int handleLobbyCommand(ServerPlayer player, MinecraftServer server) { + ResourceKey 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, server, true) ? 1 : 0; + } + + private static int reloadAll(MinecraftServer server, CommandSourceStack source) { if (!CONFIG.load()) { - source.sendFailure(Component.literal("§cLobby Systems: reload gagal. Cek console untuk detail error JSON/config.")); + source.sendFailure(Component.literal("§cLobby Systems: reload config gagal. Cek console untuk detail JSON.")); return 0; } @@ -130,58 +163,185 @@ public final class LobbySystemsMod implements ModInitializer { try { key = lobbyKey(); if (server.getLevel(key) == null) { - source.sendFailure(Component.literal("§eConfig berhasil dibaca, tetapi lobbyDimension '" + CONFIG.get().lobbyDimension + "' tidak sedang ter-load.")); + source.sendFailure(Component.literal("§eConfig terbaca, 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)); + source.sendFailure(Component.literal("§cConfig terbaca, 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); + boolean portalOk = PORTALS.load(); + if (!portalOk) { + source.sendFailure(Component.literal("§eConfig utama direload, tetapi portals.json bermasalah. Cek console.")); + return 0; + } source.sendSuccess(() -> Component.literal( - "§aLobby Systems config direload. §7Lobby: §f" + CONFIG.get().lobbyDimension - + " §7| Minimum play: §f" + CONFIG.get().minimumPlaySeconds + "s"), false); + "§aLobby Systems direload. §7Lobby: §f" + CONFIG.get().lobbyDimension + + " §7| Minimum play: §f" + CONFIG.get().minimumPlaySeconds + "s" + + " §7| Portals: §f" + PORTALS.size()), false); return 1; } - private static int teleportToDimension(ServerPlayer player, MinecraftServer server, String dimensionId) { - final Identifier id; + private static int createPortal(ServerPlayer player, MinecraftServer server, String rawName, String rawTarget) { + String name = PortalManager.normalizeName(rawName); + if (!name.matches("[a-z0-9_-]+")) { + player.sendSystemMessage(Component.literal("§cNama portal hanya boleh berisi a-z, 0-9, _ dan -.")); + return 0; + } + if (PORTALS.get(name) != null) { + player.sendSystemMessage(Component.literal("§cPortal '" + name + "' sudah ada. Hapus dulu dengan /ls portal delete " + name)); + return 0; + } + + String targetId = rawTarget.trim(); + if (findLevel(server, targetId) == null) { + player.sendSystemMessage(Component.literal("§cTarget dimension '" + targetId + "' tidak ditemukan atau tidak ter-load.")); + return 0; + } + try { - id = Identifier.parse(dimensionId.trim()); + WorldEditSelectionBridge.Selection s = WorldEditSelectionBridge.readSelection(player); + PortalDefinition portal = new PortalDefinition( + player.level().dimension().identifier().toString(), + new PortalPoint(s.minX(), s.minY(), s.minZ()), + new PortalPoint(s.maxX(), s.maxY(), s.maxZ()), + targetId + ); + if (!PORTALS.create(name, portal)) { + player.sendSystemMessage(Component.literal("§cPortal '" + name + "' sudah ada.")); + return 0; + } + + PORTALS.suppress(player.getUUID(), Math.max(CONFIG.get().portalCooldownTicks, 100)); + player.sendSystemMessage(Component.literal( + "§aPortal §f" + name + "§a dibuat. §7" + portal.sourceDimension + " §8→ §f" + targetId)); + return 1; } catch (Exception e) { - player.sendSystemMessage(Component.literal("§cDimension ID tidak valid: " + dimensionId)); + LOGGER.warn("Failed to read WorldEdit selection for {}", player.getUUID(), e); + player.sendSystemMessage(Component.literal("§cGagal membaca selection WorldEdit: " + rootMessage(e))); return 0; } + } - ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); - ServerLevel target = server.getLevel(key); + private static int deletePortal(CommandSourceStack source, String name) { + String key = PortalManager.normalizeName(name); + if (!PORTALS.delete(key)) { + source.sendFailure(Component.literal("§cPortal '" + key + "' tidak ditemukan.")); + return 0; + } + source.sendSuccess(() -> Component.literal("§aPortal §f" + key + "§a dihapus."), false); + return 1; + } + + private static int listPortals(CommandSourceStack source) { + if (PORTALS.size() == 0) { + source.sendSuccess(() -> Component.literal("§eBelum ada portal. Gunakan //wand lalu /ls portal create ."), false); + return 1; + } + source.sendSuccess(() -> Component.literal("§6Lobby Portals §7(" + PORTALS.size() + ")"), false); + for (String name : PORTALS.names()) { + PortalDefinition p = PORTALS.get(name); + source.sendSuccess(() -> Component.literal("§e- §f" + name + " §7: " + p.sourceDimension + " §8→ §f" + p.targetDimension), false); + } + return PORTALS.size(); + } + + private static int portalInfo(CommandSourceStack source, String rawName) { + String name = PortalManager.normalizeName(rawName); + PortalDefinition p = PORTALS.get(name); + if (p == null) { + source.sendFailure(Component.literal("§cPortal '" + name + "' tidak ditemukan.")); + return 0; + } + source.sendSuccess(() -> Component.literal( + "§6Portal §f" + name + + "\n§7Source: §f" + p.sourceDimension + + "\n§7Region: §f(" + p.min.x + ", " + p.min.y + ", " + p.min.z + ") §7→ §f(" + p.max.x + ", " + p.max.y + ", " + p.max.z + ")" + + "\n§7Target: §f" + p.targetDimension), false); + return 1; + } + + private static int teleportToPortal(ServerPlayer player, MinecraftServer server, String rawName) { + String name = PortalManager.normalizeName(rawName); + PortalDefinition p = PORTALS.get(name); + if (p == null) { + player.sendSystemMessage(Component.literal("§cPortal '" + name + "' tidak ditemukan.")); + return 0; + } + ServerLevel source = findLevel(server, p.sourceDimension); + if (source == null) { + player.sendSystemMessage(Component.literal("§cSource dimension portal tidak ditemukan: " + p.sourceDimension)); + return 0; + } + if (TELEPORTS.isLocked(player)) return 0; + POSITIONS.captureCurrent(player); + // /ls portal tp adalah command maintenance: cegah portal langsung memicu + // ketika admin tiba di dalam region portal. + PORTALS.suppress(player.getUUID(), Math.max(CONFIG.get().portalCooldownTicks, 200)); + boolean ok = TELEPORTS.teleport(player, source, p.centerX(), p.centerY(), p.centerZ(), player.getYRot(), player.getXRot()); + if (ok) player.sendSystemMessage(Component.literal("§aTeleport admin ke portal §f" + name + "§a.")); + return ok ? 1 : 0; + } + + private static boolean activatePortal(ServerPlayer player, MinecraftServer server, String portalName, PortalDefinition portal) { + if (TELEPORTS.isLocked(player)) return false; + + ResourceKey lobby = lobbyKey(); + ServerLevel target = findLevel(server, portal.targetDimension); if (target == null) { - player.sendSystemMessage(Component.literal("§cDimension '" + dimensionId + "' tidak ditemukan atau tidak ter-load.")); + player.sendSystemMessage(Component.literal("§cPortal '" + portalName + "' tidak dapat digunakan: target dimension tidak ter-load.")); + LOGGER.warn("Portal {} target dimension {} is not loaded.", portalName, portal.targetDimension); + return true; + } + + // Portal yang mengarah kembali ke Lobby tetap menghormati minimum playtime. + if (target.dimension().equals(lobby) && !player.level().dimension().equals(lobby)) { + long remaining = SESSIONS.remainingSeconds(player.getUUID(), CONFIG.get().minimumPlaySeconds); + if (remaining > 0) { + player.sendSystemMessage(Component.literal(CONFIG.get().waitMessage.replace("{time}", formatDuration(remaining)))); + return true; + } + POSITIONS.captureCurrent(player); + return teleportToLobby(player, server, true); + } + + int result = teleportToDimension(player, server, portal.targetDimension, false, false); + if (result > 0) { + player.sendSystemMessage(Component.literal("§aMemasuki portal §f" + portalName + "§a → §f" + portal.targetDimension)); + } + return true; + } + + /** + * @param sendMessage tampilkan pesan /ls tp + * @param adminRequest true jika dipanggil melalui command admin; portal memakai false + */ + private static int teleportToDimension(ServerPlayer player, MinecraftServer server, String dimensionId, + boolean sendMessage, boolean adminRequest) { + ServerLevel target = findLevel(server, dimensionId.trim()); + if (target == null) { + if (sendMessage || adminRequest) player.sendSystemMessage(Component.literal("§cDimension '" + dimensionId + "' tidak ditemukan atau tidak ter-load.")); return 0; } + ResourceKey key = target.dimension(); ResourceKey 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 (ok && sendMessage) player.sendSystemMessage(Component.literal("§aTeleport ke lobby pada posisi spawn yang dikonfigurasi.")); + return ok ? 1 : 0; } if (player.level().dimension().equals(key)) { - player.sendSystemMessage(Component.literal("§eKamu sudah berada di dimension " + dimensionId + ".")); + if (sendMessage) 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.")); + if (sendMessage) player.sendSystemMessage(Component.literal("§eTeleport sebelumnya masih diproses. Tunggu sebentar lalu coba lagi.")); return 0; } @@ -213,17 +373,26 @@ public final class LobbySystemsMod implements ModInitializer { } boolean ok = TELEPORTS.teleport(player, target, x, y, z, yaw, pitch); - if (!ok) { - player.sendSystemMessage(Component.literal("§cTeleport ke " + dimensionId + " gagal.")); - return 0; - } + if (!ok) 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.")); + if (sendMessage) { + 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 ServerLevel findLevel(MinecraftServer server, String dimensionId) { + try { + Identifier id = Identifier.parse(dimensionId.trim()); + ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); + return server.getLevel(key); + } catch (Exception e) { + return null; + } + } + private static ResourceKey lobbyKey() { Identifier id = Identifier.parse(CONFIG.get().lobbyDimension); return ResourceKey.create(Registries.DIMENSION, id); @@ -247,13 +416,8 @@ public final class LobbySystemsMod implements ModInitializer { } 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)); - } + if (ok && sendMessage) player.sendSystemMessage(Component.literal(c.returnedMessage)); return ok; } @@ -262,4 +426,11 @@ public final class LobbySystemsMod implements ModInitializer { long secs = seconds % 60; return minutes > 0 ? minutes + "m " + secs + "s" : secs + "s"; } + + private static String rootMessage(Throwable e) { + Throwable current = e; + while (current.getCause() != null) current = current.getCause(); + String message = current.getMessage(); + return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; + } } diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalData.java b/src/main/java/id/dartokloning/lobbysystems/PortalData.java new file mode 100644 index 0000000..b517c11 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/PortalData.java @@ -0,0 +1,8 @@ +package id.dartokloning.lobbysystems; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class PortalData { + public Map portals = new LinkedHashMap<>(); +} diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java b/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java new file mode 100644 index 0000000..615d1fd --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java @@ -0,0 +1,37 @@ +package id.dartokloning.lobbysystems; + +public final class PortalDefinition { + public String sourceDimension; + public PortalPoint min; + public PortalPoint max; + public String targetDimension; + + public PortalDefinition() {} + + public PortalDefinition(String sourceDimension, PortalPoint min, PortalPoint max, String targetDimension) { + this.sourceDimension = sourceDimension; + this.min = min; + this.max = max; + this.targetDimension = targetDimension; + } + + public boolean contains(double x, double y, double z) { + if (min == null || max == null) return false; + int minX = Math.min(min.x, max.x); + int minY = Math.min(min.y, max.y); + int minZ = Math.min(min.z, max.z); + int maxX = Math.max(min.x, max.x); + int maxY = Math.max(min.y, max.y); + int maxZ = Math.max(min.z, max.z); + + // WorldEdit selection adalah blok inklusif. Coordinate player memakai double, + // jadi sisi atas region berakhir tepat sebelum max + 1. + return x >= minX && x < maxX + 1.0 + && y >= minY && y < maxY + 1.0 + && z >= minZ && z < maxZ + 1.0; + } + + public double centerX() { return (Math.min(min.x, max.x) + Math.max(min.x, max.x) + 1.0) / 2.0; } + public double centerY() { return Math.min(min.y, max.y) + 0.1; } + public double centerZ() { return (Math.min(min.z, max.z) + Math.max(min.z, max.z) + 1.0) / 2.0; } +} diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalManager.java b/src/main/java/id/dartokloning/lobbysystems/PortalManager.java new file mode 100644 index 0000000..cd719cf --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/PortalManager.java @@ -0,0 +1,150 @@ +package id.dartokloning.lobbysystems; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public final class PortalManager { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path PATH = ConfigPaths.PORTALS; + + private final Map portals = new LinkedHashMap<>(); + private final Map>> byDimension = new HashMap<>(); + private final Map cooldownUntil = new HashMap<>(); + private long tick; + + public boolean load() { + portals.clear(); + if (!Files.exists(PATH)) { + save(); + rebuildIndex(); + return true; + } + + try (Reader reader = Files.newBufferedReader(PATH)) { + PortalData data = GSON.fromJson(reader, PortalData.class); + if (data != null && data.portals != null) portals.putAll(data.portals); + validateAll(); + rebuildIndex(); + return true; + } catch (Exception e) { + LobbySystemsMod.LOGGER.error("Failed to load {}. Keeping portal list empty.", PATH, e); + portals.clear(); + rebuildIndex(); + return false; + } + } + + public void save() { + try { + Files.createDirectories(PATH.getParent()); + PortalData data = new PortalData(); + data.portals.putAll(portals); + try (Writer writer = Files.newBufferedWriter(PATH)) { + GSON.toJson(data, writer); + } + } catch (Exception e) { + LobbySystemsMod.LOGGER.error("Failed to save portals to {}", PATH, e); + } + } + + public boolean create(String name, PortalDefinition portal) { + String key = normalizeName(name); + if (portals.containsKey(key)) return false; + validate(key, portal); + portals.put(key, portal); + rebuildIndex(); + save(); + return true; + } + + public boolean delete(String name) { + PortalDefinition removed = portals.remove(normalizeName(name)); + if (removed == null) return false; + rebuildIndex(); + save(); + return true; + } + + public PortalDefinition get(String name) { + return portals.get(normalizeName(name)); + } + + public Set names() { + return Collections.unmodifiableSet(portals.keySet()); + } + + public int size() { + return portals.size(); + } + + public void tick(MinecraftServer server, int intervalTicks, int cooldownTicks, PortalTrigger trigger) { + tick++; + cooldownUntil.entrySet().removeIf(e -> e.getValue() <= tick); + if (tick % Math.max(1, intervalTicks) != 0) return; + + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (cooldownUntil.getOrDefault(player.getUUID(), 0L) > tick) continue; + + String dimension = player.level().dimension().identifier().toString(); + List> candidates = byDimension.get(dimension); + if (candidates == null || candidates.isEmpty()) continue; + + for (Map.Entry entry : candidates) { + PortalDefinition portal = entry.getValue(); + if (!portal.contains(player.getX(), player.getY(), player.getZ())) continue; + + if (trigger.activate(player, entry.getKey(), portal)) { + cooldownUntil.put(player.getUUID(), tick + Math.max(1, cooldownTicks)); + } + break; + } + } + } + + public void suppress(UUID uuid, int ticks) { + cooldownUntil.put(uuid, tick + Math.max(1, ticks)); + } + + public void onDisconnect(UUID uuid) { + cooldownUntil.remove(uuid); + } + + private void validateAll() { + for (Map.Entry e : portals.entrySet()) validate(e.getKey(), e.getValue()); + } + + private static void validate(String name, PortalDefinition p) { + if (!name.matches("[a-z0-9_-]+")) { + throw new IllegalArgumentException("Nama portal hanya boleh berisi a-z, 0-9, _ dan -: " + name); + } + if (p == null || p.sourceDimension == null || p.sourceDimension.isBlank() + || p.targetDimension == null || p.targetDimension.isBlank() + || p.min == null || p.max == null) { + throw new IllegalArgumentException("Portal '" + name + "' tidak lengkap."); + } + } + + private void rebuildIndex() { + byDimension.clear(); + for (Map.Entry e : portals.entrySet()) { + byDimension.computeIfAbsent(e.getValue().sourceDimension, ignored -> new ArrayList<>()).add(e); + } + } + + public static String normalizeName(String name) { + return name.trim().toLowerCase(Locale.ROOT); + } + + @FunctionalInterface + public interface PortalTrigger { + boolean activate(ServerPlayer player, String portalName, PortalDefinition portal); + } +} diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalPoint.java b/src/main/java/id/dartokloning/lobbysystems/PortalPoint.java new file mode 100644 index 0000000..49920b4 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/PortalPoint.java @@ -0,0 +1,15 @@ +package id.dartokloning.lobbysystems; + +public final class PortalPoint { + public int x; + public int y; + public int z; + + public PortalPoint() {} + + public PortalPoint(int x, int y, int z) { + this.x = x; + this.y = y; + this.z = z; + } +} diff --git a/src/main/java/id/dartokloning/lobbysystems/PositionManager.java b/src/main/java/id/dartokloning/lobbysystems/PositionManager.java index b6bd065..74534ff 100644 --- a/src/main/java/id/dartokloning/lobbysystems/PositionManager.java +++ b/src/main/java/id/dartokloning/lobbysystems/PositionManager.java @@ -3,7 +3,6 @@ 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; @@ -19,7 +18,7 @@ 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 Path PATH = ConfigPaths.PLAYER_POSITIONS; private static final Type DATA_TYPE = new TypeToken>>() {}.getType(); private final Map> positions = new HashMap<>(); @@ -29,7 +28,10 @@ public final class PositionManager { public void load() { positions.clear(); - if (!Files.exists(PATH)) return; + if (!Files.exists(PATH)) { + save(); + return; + } try (Reader reader = Files.newBufferedReader(PATH)) { Map> loaded = GSON.fromJson(reader, DATA_TYPE); diff --git a/src/main/java/id/dartokloning/lobbysystems/WorldEditSelectionBridge.java b/src/main/java/id/dartokloning/lobbysystems/WorldEditSelectionBridge.java new file mode 100644 index 0000000..60dec65 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/WorldEditSelectionBridge.java @@ -0,0 +1,83 @@ +package id.dartokloning.lobbysystems; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.level.ServerPlayer; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +/** + * Soft integration dengan WorldEdit. Reflection sengaja dipakai agar Lobby Systems + * tetap dapat dijalankan tanpa WorldEdit; portal yang sudah tersimpan tetap bekerja. + */ +public final class WorldEditSelectionBridge { + private WorldEditSelectionBridge() {} + + public static boolean isAvailable() { + return FabricLoader.getInstance().isModLoaded("worldedit"); + } + + public static Selection readSelection(ServerPlayer player) throws Exception { + if (!isAvailable()) { + throw new IllegalStateException("WorldEdit tidak terpasang. Install WorldEdit untuk memakai selection //wand."); + } + + Class adapterClass = Class.forName("com.sk89q.worldedit.fabric.FabricAdapter"); + Object actor; + try { + actor = invokeCompatibleStatic(adapterClass, "adaptPlayer", player); + } catch (NoSuchMethodException ignored) { + actor = invokeCompatibleStatic(adapterClass, "adapt", player); + } + if (actor == null) throw new IllegalStateException("FabricAdapter tidak dapat mengadaptasi player."); + + Class worldEditClass = Class.forName("com.sk89q.worldedit.WorldEdit"); + Object worldEdit = worldEditClass.getMethod("getInstance").invoke(null); + Object sessionManager = worldEdit.getClass().getMethod("getSessionManager").invoke(worldEdit); + Object session = invokeCompatible(sessionManager, "get", actor); + Object world = actor.getClass().getMethod("getWorld").invoke(actor); + + Object region; + try { + region = invokeCompatible(session, "getSelection", world); + } catch (Exception e) { + throw new IllegalStateException("Selection WorldEdit belum lengkap. Gunakan //wand lalu pilih pos1 dan pos2.", unwrap(e)); + } + + Object min = region.getClass().getMethod("getMinimumPoint").invoke(region); + Object max = region.getClass().getMethod("getMaximumPoint").invoke(region); + + return new Selection( + intValue(min, "getBlockX"), intValue(min, "getBlockY"), intValue(min, "getBlockZ"), + intValue(max, "getBlockX"), intValue(max, "getBlockY"), intValue(max, "getBlockZ") + ); + } + + private static int intValue(Object object, String method) throws Exception { + return ((Number) object.getClass().getMethod(method).invoke(object)).intValue(); + } + + private static Object invokeCompatibleStatic(Class type, String name, Object argument) throws Exception { + for (Method m : type.getMethods()) { + if (!m.getName().equals(name) || !Modifier.isStatic(m.getModifiers()) || m.getParameterCount() != 1) continue; + if (m.getParameterTypes()[0].isAssignableFrom(argument.getClass())) return m.invoke(null, argument); + } + throw new NoSuchMethodException(type.getName() + "." + name + "(compatible)"); + } + + private static Object invokeCompatible(Object target, String name, Object argument) throws Exception { + for (Method m : target.getClass().getMethods()) { + if (!m.getName().equals(name) || m.getParameterCount() != 1) continue; + if (m.getParameterTypes()[0].isAssignableFrom(argument.getClass())) return m.invoke(target, argument); + } + throw new NoSuchMethodException(target.getClass().getName() + "." + name + "(compatible)"); + } + + private static Throwable unwrap(Throwable t) { + Throwable current = t; + while (current.getCause() != null) current = current.getCause(); + return current; + } + + public record Selection(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {} +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 9b4ce66..6f7facf 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "dartokloning", "version": "${version}", "name": "Lobby Systems", - "description": "Server-side Fabric lobby routing and minimum game-session timer.", + "description": "Server-side Fabric lobby routing, persistent world positions, minimum playtime, and WorldEdit-selected dimension portals.", "authors": ["DartoKloning"], "license": "MIT", "environment": "server", @@ -15,5 +15,8 @@ "minecraft": "~26.2", "java": ">=25", "fabric-api": "*" + }, + "suggests": { + "worldedit": "*" } }