From b24e031b058cc83c86afd497408d1293a4ca2bce Mon Sep 17 00:00:00 2001 From: Darto KLoning Date: Sun, 6 Sep 2026 23:03:00 +0700 Subject: [PATCH] Bugfix portal visual systems --- README.md | 61 ++++-- gradle.properties | 2 +- .../lobbysystems/LobbySystemsMod.java | 66 +++++- .../lobbysystems/PortalDefinition.java | 15 ++ .../lobbysystems/PortalManager.java | 18 ++ .../lobbysystems/PortalVisualManager.java | 188 ++++++++++++++++++ 6 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 src/main/java/id/dartokloning/lobbysystems/PortalVisualManager.java diff --git a/README.md b/README.md index 61234c7..22e544e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Lobby Systems v1.4.1 — Fabric 26.2 +# Lobby Systems v1.5.1 — Fabric 26.2 -Server-side lobby routing untuk Minecraft 26.2/Fabric. v1.4.1 mempertahankan native persistent per-dimension gamerules dan menambahkan compatibility control untuk dimension gamemode serta perbaikan WorldEdit 7.4.x portal selection. +Server-side lobby routing untuk Minecraft 26.2/Fabric. v1.5.1 memperbaiki compatibility compile Minecraft 26.2 pada Portal Visual System yang diperkenalkan di v1.5.0. server-only berbasis visual vanilla `minecraft:nether_portal`, lengkap dengan orientasi X/Z otomatis dari WorldEdit selection. Native per-dimension gamerules, dimension gamemode management, dan WorldEdit 7.4.x bridge tetap dipertahankan. -**v1.4.1 compatibility/bugfix release:** `manageDimensionGamemode` (default `true`) menentukan apakah Lobby Systems menjadi source of truth gamemode per dimension. Jika Dimensional Inventories terdeteksi saat opsi ini aktif, console menampilkan compatibility warning agar konfigurasi gamemode kedua mod disamakan. Integrasi WorldEdit juga diperbarui untuk API Fabric WorldEdit 7.4.x (`FabricAdapter.get().fromNativePlayer(...)`). +**v1.5.1 bugfix:** memperbaiki penggunaan API `ServerPlayer` saat membuat/membersihkan visual portal. Fitur Portal Visual System tetap sama seperti v1.5.0. + +**v1.5.0 feature release:** Portal Visual System menambahkan permukaan swirl ungu memakai block state vanilla `minecraft:nether_portal`, dengan axis X/Z yang ditentukan otomatis dari WorldEdit selection. Frame boleh memakai block apa saja. `manageDimensionGamemode` tetap default `true`, compatibility warning Dimensional Inventories tetap aktif, dan bridge WorldEdit 7.4.x tetap dipakai. ### Reload feedback @@ -19,6 +21,7 @@ Server-side lobby routing untuk Minecraft 26.2/Fabric. v1.4.1 mempertahankan nat - Mod ID: `dartokloning` - Java namespace: `id.dartokloning.lobbysystems` - WorldEdit: opsional untuk runtime portal, diperlukan saat membuat portal memakai `//wand` +- Portal Visual System v1.5.0: memakai block state vanilla `minecraft:nether_portal`, sehingga tidak membutuhkan mod/resource pack di client ## Compile di Windows @@ -40,7 +43,7 @@ chmod +x compile.sh release.sh ```text ============================================ Lobby Systems - Fabric 26.2 Build - Version: 1.4.1 + Version: 1.5.1 Namespace: id.dartokloning.lobbysystems Mod ID: dartokloning ============================================ @@ -49,7 +52,7 @@ chmod +x compile.sh release.sh Output utama: ```text -build/libs/lobby-systems-1.4.1.jar +build/libs/lobby-systems-1.5.1.jar ``` `*-sources.jar` hanya source archive dan tidak perlu dipasang ke server. @@ -80,14 +83,14 @@ State penting Lobby Systems memakai separator agar mudah terlihat di server cons ```text ============================================ Lobby Systems - READY - Version: 1.4.1 + Version: 1.5.1 Minecraft: 26.2 Config directory: config/lobby WorldEdit: available ============================================ ``` -## Struktur konfigurasi v1.4.1 +## Struktur konfigurasi v1.5.0 ```text config/ @@ -148,13 +151,15 @@ Portal dibuat lewat command dan tersimpan otomatis. Contoh: "y": 67, "z": -5 }, - "targetDimension": "minecraft:overworld" + "targetDimension": "minecraft:overworld", + "visualMode": "nether_portal", + "visualAxis": "x" } } } ``` -Portal adalah region/cuboid, bukan harus Nether Portal asli. Anda bebas membuat bentuk doorway, arch, lantai, gateway, dan sebagainya di dalam region tersebut. +Trigger portal tetap berupa region Lobby Systems. Mulai v1.5.0, portal baru yang dibuat lewat command menyimpan `visualMode=nether_portal` dan `visualAxis=x|z`. Frame/dekorasinya bebas, tetapi selection portal visual harus berupa bidang vertikal 1 block tebal dan hanya mencakup area dalam portal. ### `config/lobby/dimension-gamemodes.json` @@ -296,24 +301,46 @@ Reload live: Tidak perlu restart untuk perubahan JSON tersebut. -### Membuat portal dengan WorldEdit +### Membuat portal dengan WorldEdit + visual portal -1. Pastikan WorldEdit Fabric 26.2 terpasang di server. -2. Masuk ke dimension tempat portal akan berada, biasanya Lobby. -3. Gunakan: +Mulai v1.5.0, `/ls portal create` dapat membuat **visual swirl vanilla Nether Portal secara otomatis** tanpa Flint and Steel dan tanpa mewajibkan mod Lobby Systems pada client. Lobby Systems memakai resource/block vanilla `minecraft:nether_portal`; tidak ada asset Custom Portals yang disalin. + +Frame portal boleh dibuat dari block apa saja. Yang penting, WorldEdit selection harus memilih **area dalam/permukaan aktif portal saja**, bukan frame. Selection harus berupa bidang vertikal setebal tepat 1 block pada salah satu axis horizontal. Contoh valid: ```text -//wand +1 x 5 x 3 (portal membentang pada axis Z) +3 x 5 x 1 (portal membentang pada axis X) ``` -4. Select dua sudut cuboid portal. +Contoh tidak valid untuk visual vanilla: + +```text +3 x 5 x 3 (volume terlalu tebal) +5 x 1 x 5 (portal horizontal tidak didukung oleh nether_portal vanilla) +``` + +Workflow: + +1. Bangun frame/dekorasi portal dari block apa saja. +2. Masuk ke dimension tempat portal berada, biasanya Lobby. +3. Jalankan `//wand`. +4. Select dua sudut **bagian dalam portal**. Jangan sertakan frame. 5. Buat portal: ```text /ls portal create survival minecraft:overworld ``` -Target dimension memiliki autocomplete dari daftar dimension yang sedang loaded. +Lobby Systems otomatis mendeteksi orientasi selection: + +- selection dengan X tetap dan Z melebar -> `nether_portal[axis=z]` +- selection dengan Z tetap dan X melebar -> `nether_portal[axis=x]` + +Ini memperbaiki kasus ketika `/fill ... minecraft:nether_portal` memakai default axis yang salah sehingga tekstur portal terlihat menyamping. Portal visual juga dipulihkan secara berkala jika block portal vanilla menghilang akibat shape update pada frame non-obsidian. Teleport tetap ditentukan oleh `portals.json`/Lobby Systems, bukan oleh destination Nether Portal vanilla. Saat player berada di managed visual region, Lobby Systems menjaga vanilla portal cooldown tetap aktif agar mekanisme Nether Portal vanilla tidak mengambil alih destination, termasuk untuk player Creative. + +Untuk keamanan, saat membuat portal baru Lobby Systems hanya akan mengganti air, fire, soul fire, atau nether portal di dalam selection. Jika selection mengenai block frame/dekorasi, pembuatan dibatalkan agar build tidak tertimpa. + +Portal lama dari sebelum v1.5.0 tetap berfungsi sebagai trigger tetapi **tidak otomatis diberi visual**, karena selection lama mungkin mencakup frame. Hapus dan buat ulang portal tersebut dengan selection interior jika ingin visual baru. Contoh lain: @@ -324,7 +351,7 @@ Contoh lain: Nama portal hanya menerima huruf kecil `a-z`, angka `0-9`, `_`, dan `-`. -WorldEdit hanya dibutuhkan saat membaca selection untuk **membuat** portal. v1.4.1 memakai bridge WorldEdit Fabric 7.4.x melalui `FabricAdapter.get().fromNativePlayer(...)` dengan fallback API lama. Setelah `portals.json` tersimpan, portal runtime dikelola Lobby Systems sendiri. Jika WorldEdit dilepas kemudian, portal yang sudah ada tetap dapat bekerja. +WorldEdit hanya dibutuhkan saat membaca selection untuk **membuat** portal. Setelah `portals.json` tersimpan, portal runtime dan visual recovery dikelola Lobby Systems sendiri. Jika WorldEdit dilepas kemudian, portal yang sudah ada tetap dapat bekerja. ### Menghapus dan mengecek portal diff --git a/gradle.properties b/gradle.properties index bd2d1cb..f9c96e4 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.4.1 +mod_version=1.5.1 maven_group=id.dartokloning archives_base_name=lobby-systems diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java index 50cea48..5e18551 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java @@ -40,6 +40,7 @@ public final class LobbySystemsMod implements ModInitializer { private static final SessionTracker SESSIONS = new SessionTracker(); private static final PositionManager POSITIONS = new PositionManager(); private static final PortalManager PORTALS = new PortalManager(); + private static final PortalVisualManager PORTAL_VISUALS = new PortalVisualManager(); private static final TeleportCoordinator TELEPORTS = new TeleportCoordinator(); private static final DimensionGameModeManager DIMENSION_GAMEMODES = new DimensionGameModeManager(); private static final DimensionGameRuleManager DIMENSION_GAMERULES = new DimensionGameRuleManager(); @@ -98,6 +99,16 @@ public final class LobbySystemsMod implements ModInitializer { "Rules applied: " + summary.applied(), "Rules skipped: " + summary.skipped() ); + + int visualBlocks = PORTAL_VISUALS.repairAll(server, PORTALS, false); + if (visualBlocks > 0) { + LobbyLog.state( + "PORTAL VISUALS RESTORED", + "Visual blocks restored: " + visualBlocks, + "Visual type: vanilla nether_portal", + "Client mod required: no" + ); + } }); ServerLevelEvents.LOAD.register((server, world) -> { @@ -110,6 +121,12 @@ public final class LobbySystemsMod implements ModInitializer { "Rules skipped: " + summary.skipped() ); } + + int visualBlocks = PORTAL_VISUALS.applyToLevel(world, PORTALS); + if (visualBlocks > 0) { + LOGGER.info("Restored {} Lobby Systems portal visual block(s) in {}.", + visualBlocks, world.dimension().identifier()); + } }); ServerPlayConnectionEvents.JOIN.register((listener, sender, server) -> { @@ -161,6 +178,7 @@ public final class LobbySystemsMod implements ModInitializer { LobbyConfig cfg = CONFIG.get(); PORTALS.tick(server, cfg.portalCheckIntervalTicks, cfg.portalCooldownTicks, (player, portalName, portal) -> activatePortal(player, server, portalName, portal)); + PORTAL_VISUALS.tick(server, PORTALS); if (++saveTicker >= 1200) { saveTicker = 0; @@ -351,6 +369,7 @@ public final class LobbySystemsMod implements ModInitializer { LobbyLog.state("RELOAD FAILED", "Reason: portals.json gagal dibaca"); return 0; } + int repairedPortalVisuals = PORTAL_VISUALS.repairAll(server, PORTALS, false); boolean gameModesOk = DIMENSION_GAMEMODES.load(); if (!gameModesOk) { @@ -385,6 +404,7 @@ public final class LobbySystemsMod implements ModInitializer { "Lobby: " + CONFIG.get().lobbyDimension, "Minimum play: " + CONFIG.get().minimumPlaySeconds + "s", "Portals: " + PORTALS.size(), + "Portal visual blocks restored: " + repairedPortalVisuals, "Dimension gamemodes: " + DIMENSION_GAMEMODES.all().size() + " (management " + (CONFIG.get().manageDimensionGamemode ? "enabled" : "disabled") + ")", "Dimension gamerules: " + DIMENSION_GAMERULES.all().size() + " dimensions / " + gameRuleSummary.applied() + " rules applied", @@ -697,42 +717,66 @@ public final class LobbySystemsMod implements ModInitializer { try { WorldEditSelectionBridge.Selection s = WorldEditSelectionBridge.readSelection(player); + String visualAxis = PORTAL_VISUALS.determineAxis(s, 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 + targetId, + "nether_portal", + visualAxis ); + + // Apply the visual before persisting the portal so a bad selection can + // fail safely without leaving a half-created portal definition. + int visualBlocks = PORTAL_VISUALS.applyInitial((ServerLevel) player.level(), portal); if (!PORTALS.create(name, portal)) { + PORTAL_VISUALS.clear((ServerLevel) player.level(), 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)); + "§aPortal §f" + name + "§a dibuat. §7" + portal.sourceDimension + " §8→ §f" + targetId + + "\n§dVisual portal aktif §7(" + visualBlocks + " blok, axis=" + visualAxis + ")." + + "\n§7Frame boleh block apa saja; selection harus hanya area dalam portal.")); LobbyLog.state( "PORTAL CREATED", "Name: " + name, "Source: " + portal.sourceDimension, - "Target: " + targetId + "Target: " + targetId, + "Visual: vanilla nether_portal", + "Axis: " + visualAxis, + "Visual blocks: " + visualBlocks ); return 1; } catch (Exception e) { - LOGGER.warn("Failed to read WorldEdit selection for {}", player.getUUID(), e); - player.sendSystemMessage(Component.literal("§cGagal membaca selection WorldEdit: " + rootMessage(e))); + LOGGER.warn("Failed to create portal for {}", player.getUUID(), e); + player.sendSystemMessage(Component.literal("§cGagal membuat portal: " + rootMessage(e))); return 0; } } private static int deletePortal(CommandSourceStack source, String name) { String key = PortalManager.normalizeName(name); - if (!PORTALS.delete(key)) { + PortalDefinition existing = PORTALS.get(key); + if (existing == null || !PORTALS.delete(key)) { source.sendFailure(Component.literal("§cPortal '" + key + "' tidak ditemukan.")); return 0; } - source.sendSuccess(() -> Component.literal("§aPortal §f" + key + "§a dihapus."), false); - LobbyLog.state("PORTAL DELETED", "Name: " + key); + + int cleared = 0; + ServerLevel sourceLevel = findLevel(source.getServer(), existing.sourceDimension); + if (sourceLevel != null) { + cleared = PORTAL_VISUALS.clear(sourceLevel, existing); + } + + int finalCleared = cleared; + source.sendSuccess(() -> Component.literal( + "§aPortal §f" + key + "§a dihapus." + + (finalCleared > 0 ? " §7Visual dibersihkan: §f" + finalCleared + " blok." : "")), false); + LobbyLog.state("PORTAL DELETED", "Name: " + key, "Visual blocks cleared: " + cleared); return 1; } @@ -744,7 +788,8 @@ public final class LobbySystemsMod implements ModInitializer { 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); + source.sendSuccess(() -> Component.literal("§e- §f" + name + " §7: " + p.sourceDimension + " §8→ §f" + p.targetDimension + + (p.hasNetherPortalVisual() ? " §d[visual]" : " §8[legacy/no visual]")), false); } return PORTALS.size(); } @@ -760,7 +805,8 @@ public final class LobbySystemsMod implements ModInitializer { "§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); + + "\n§7Target: §f" + p.targetDimension + + "\n§7Visual: §f" + (p.hasNetherPortalVisual() ? "nether_portal (axis=" + p.visualAxis + ")" : "none / legacy")), false); return 1; } diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java b/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java index 615d1fd..055e240 100644 --- a/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java +++ b/src/main/java/id/dartokloning/lobbysystems/PortalDefinition.java @@ -6,13 +6,28 @@ public final class PortalDefinition { public PortalPoint max; public String targetDimension; + // Added in v1.5.0. Legacy portals without these fields stay visual-free. + public String visualMode = "none"; + public String visualAxis = null; + public PortalDefinition() {} public PortalDefinition(String sourceDimension, PortalPoint min, PortalPoint max, String targetDimension) { + this(sourceDimension, min, max, targetDimension, "none", null); + } + + public PortalDefinition(String sourceDimension, PortalPoint min, PortalPoint max, String targetDimension, + String visualMode, String visualAxis) { this.sourceDimension = sourceDimension; this.min = min; this.max = max; this.targetDimension = targetDimension; + this.visualMode = visualMode; + this.visualAxis = visualAxis; + } + + public boolean hasNetherPortalVisual() { + return "nether_portal".equalsIgnoreCase(visualMode); } public boolean contains(double x, double y, double z) { diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalManager.java b/src/main/java/id/dartokloning/lobbysystems/PortalManager.java index cd719cf..fdf1dd9 100644 --- a/src/main/java/id/dartokloning/lobbysystems/PortalManager.java +++ b/src/main/java/id/dartokloning/lobbysystems/PortalManager.java @@ -88,6 +88,24 @@ public final class PortalManager { public void tick(MinecraftServer server, int intervalTicks, int cooldownTicks, PortalTrigger trigger) { tick++; cooldownUntil.entrySet().removeIf(e -> e.getValue() <= tick); + + // v1.5.0 uses vanilla nether_portal blocks only as a visual layer. + // Keep Minecraft's own Nether portal processor on cooldown while a player + // is inside a managed visual region, so Lobby Systems remains the sole + // owner of the destination (important for Creative players as well). + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + String dimension = player.level().dimension().identifier().toString(); + List> candidates = byDimension.get(dimension); + if (candidates == null) continue; + for (Map.Entry entry : candidates) { + PortalDefinition portal = entry.getValue(); + if (portal.hasNetherPortalVisual() && portal.contains(player.getX(), player.getY(), player.getZ())) { + player.setPortalCooldown(20); + break; + } + } + } + if (tick % Math.max(1, intervalTicks) != 0) return; for (ServerPlayer player : server.getPlayerList().getPlayers()) { diff --git a/src/main/java/id/dartokloning/lobbysystems/PortalVisualManager.java b/src/main/java/id/dartokloning/lobbysystems/PortalVisualManager.java new file mode 100644 index 0000000..e00e441 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/PortalVisualManager.java @@ -0,0 +1,188 @@ +package id.dartokloning.lobbysystems; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.NetherPortalBlock; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Server-only visual layer for Lobby Systems portals. + * + * It deliberately uses Minecraft's existing nether_portal block state, so clients + * need no Lobby Systems mod/resource pack. Teleport routing remains owned by + * PortalManager; the vanilla block is only used as the animated purple surface. + */ +public final class PortalVisualManager { + private static final int REPAIR_INTERVAL_TICKS = 20; + private int repairTicker; + + public String determineAxis(WorldEditSelectionBridge.Selection selection, ServerPlayer player) { + int sizeX = Math.abs(selection.maxX() - selection.minX()) + 1; + int sizeY = Math.abs(selection.maxY() - selection.minY()) + 1; + int sizeZ = Math.abs(selection.maxZ() - selection.minZ()) + 1; + + if (sizeY < 1) { + throw new IllegalArgumentException("Selection portal tidak valid."); + } + + // A vertical portal surface must be one block thick on exactly one + // horizontal axis. This supports both X-facing and Z-facing portals. + if (sizeX == 1 && sizeZ > 1) return "z"; + if (sizeZ == 1 && sizeX > 1) return "x"; + + // A one-block-wide column has no geometric width axis. Pick the axis + // from the player's facing so the vanilla portal texture still renders. + if (sizeX == 1 && sizeZ == 1) { + return player.getDirection().getAxis() == Direction.Axis.Z ? "x" : "z"; + } + + throw new IllegalArgumentException( + "Selection visual portal harus berupa bidang vertikal setebal 1 blok. " + + "Select hanya area DALAM portal, contoh 1x5x3 atau 3x5x1; bukan frame/volume penuh."); + } + + public int applyInitial(ServerLevel level, PortalDefinition portal) { + if (!portal.hasNetherPortalVisual()) return 0; + ensureReplaceable(level, portal); + return apply(level, portal); + } + + public int apply(ServerLevel level, PortalDefinition portal) { + if (!portal.hasNetherPortalVisual()) return 0; + + Direction.Axis axis = parseAxis(portal.visualAxis); + BlockState visual = Blocks.NETHER_PORTAL.defaultBlockState() + .setValue(NetherPortalBlock.AXIS, axis); + + int changed = 0; + int minX = Math.min(portal.min.x, portal.max.x); + int minY = Math.min(portal.min.y, portal.max.y); + int minZ = Math.min(portal.min.z, portal.max.z); + int maxX = Math.max(portal.min.x, portal.max.x); + int maxY = Math.max(portal.min.y, portal.max.y); + int maxZ = Math.max(portal.min.z, portal.max.z); + + // UPDATE_KNOWN_SHAPE is intentional: visual portals are allowed to use + // arbitrary decorative frames and must not require vanilla obsidian shape + // validation when the block state is placed. + int flags = Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE; + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockPos pos = new BlockPos(x, y, z); + BlockState current = level.getBlockState(pos); + if (!current.equals(visual)) { + level.setBlock(pos, visual, flags); + changed++; + } + } + } + } + return changed; + } + + public int clear(ServerLevel level, PortalDefinition portal) { + if (!portal.hasNetherPortalVisual()) return 0; + int changed = 0; + int flags = Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE; + + int minX = Math.min(portal.min.x, portal.max.x); + int minY = Math.min(portal.min.y, portal.max.y); + int minZ = Math.min(portal.min.z, portal.max.z); + int maxX = Math.max(portal.min.x, portal.max.x); + int maxY = Math.max(portal.min.y, portal.max.y); + int maxZ = Math.max(portal.min.z, portal.max.z); + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockPos pos = new BlockPos(x, y, z); + if (level.getBlockState(pos).is(Blocks.NETHER_PORTAL)) { + level.setBlock(pos, Blocks.AIR.defaultBlockState(), flags); + changed++; + } + } + } + } + return changed; + } + + public void tick(MinecraftServer server, PortalManager portals) { + if (++repairTicker < REPAIR_INTERVAL_TICKS) return; + repairTicker = 0; + repairAll(server, portals, false); + } + + public int repairAll(MinecraftServer server, PortalManager portals, boolean log) { + int changed = 0; + for (String name : portals.names()) { + PortalDefinition portal = portals.get(name); + if (portal == null || !portal.hasNetherPortalVisual()) continue; + ServerLevel level = findLevel(server, portal.sourceDimension); + if (level == null) continue; + int repaired = apply(level, portal); + changed += repaired; + if (log && repaired > 0) { + LobbySystemsMod.LOGGER.info("Re-applied {} portal visual block(s) for '{}'.", repaired, name); + } + } + return changed; + } + + public int applyToLevel(ServerLevel level, PortalManager portals) { + int changed = 0; + String dimension = level.dimension().identifier().toString(); + for (String name : portals.names()) { + PortalDefinition portal = portals.get(name); + if (portal != null && portal.hasNetherPortalVisual() && dimension.equals(portal.sourceDimension)) { + changed += apply(level, portal); + } + } + return changed; + } + + private static void ensureReplaceable(ServerLevel level, PortalDefinition portal) { + int blocked = 0; + int minX = Math.min(portal.min.x, portal.max.x); + int minY = Math.min(portal.min.y, portal.max.y); + int minZ = Math.min(portal.min.z, portal.max.z); + int maxX = Math.max(portal.min.x, portal.max.x); + int maxY = Math.max(portal.min.y, portal.max.y); + int maxZ = Math.max(portal.min.z, portal.max.z); + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockState state = level.getBlockState(new BlockPos(x, y, z)); + if (!(state.isAir() || state.is(Blocks.FIRE) || state.is(Blocks.SOUL_FIRE) + || state.is(Blocks.NETHER_PORTAL))) { + blocked++; + } + } + } + } + + if (blocked > 0) { + throw new IllegalArgumentException( + "Selection menyentuh " + blocked + " blok non-kosong. " + + "Select hanya area DALAM portal; frame/dekorasi jangan ikut terseleksi."); + } + } + + private static Direction.Axis parseAxis(String axis) { + return "z".equalsIgnoreCase(axis) ? Direction.Axis.Z : Direction.Axis.X; + } + + private static ServerLevel findLevel(MinecraftServer server, String dimensionId) { + for (ServerLevel level : server.getAllLevels()) { + if (level.dimension().identifier().toString().equals(dimensionId)) return level; + } + return null; + } +}