diff --git a/integration/mineflayer/test.mjs b/integration/mineflayer/test.mjs index 2ba9d39..65931c1 100644 --- a/integration/mineflayer/test.mjs +++ b/integration/mineflayer/test.mjs @@ -56,6 +56,64 @@ function waitFor(predicate, description, timeout = timeoutMs) { }) } +async function rightClickBlock(target) { + const swingArm = bot.swingArm + bot.swingArm = () => {} + try { + const westFace = target.position.offset(-1, 0, 0).minus(target.position) + const westFaceCenter = target.position.offset(0, 0.5, 0.5).minus(target.position) + await bot.activateBlock(target, westFace, westFaceCenter) + } finally { + bot.swingArm = swingArm + } +} + +async function exerciseFreezeLifecycle(target, expectedBlockName) { + const packetOffset = entityPackets.length + await rightClickBlock(target) + + const displays = await waitFor(() => { + const packets = entityPackets.slice(packetOffset) + const itemDisplay = packets.find(packet => + packet.name.includes('spawn_entity') && packet.type === bot.registry.entitiesByName.item_display.id + ) + const blockDisplay = packets.find(packet => + packet.name.includes('spawn_entity') && packet.type === bot.registry.entitiesByName.block_display.id + ) + return itemDisplay && blockDisplay ? { itemDisplay, blockDisplay } : null + }, 'VirtualEntities Item Display and Block Display') + + await bot.waitForTicks(5) + await bot.lookAt(target.position.offset(0.5, 0.5, 0.5), true) + await rightClickBlock(target) + + await waitFor( + () => { + const destroyPackets = entityPackets.slice(packetOffset) + .filter(packet => packet.name === 'entity_destroy') + const removed = entityId => destroyPackets.some(packet => + Array.from(packet.entityIds ?? []).includes(entityId) + ) + return removed(displays.itemDisplay.entityId) && removed(displays.blockDisplay.entityId) + }, + 'VirtualEntities removal' + ) + + await bot.waitForTicks(10) + const displayEntityIds = new Set(entityPackets.slice(packetOffset) + .filter(packet => + packet.name.includes('spawn_entity') && + (packet.type === bot.registry.entitiesByName.item_display.id || + packet.type === bot.registry.entitiesByName.block_display.id) + ) + .map(packet => packet.entityId)) + assert.equal(displayEntityIds.size, 2, 'unfreezing must not freeze a block behind the target') + await waitFor( + () => bot.blockAt(target.position)?.name === expectedBlockName, + `${expectedBlockName} block restoration` + ) +} + try { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for Mineflayer spawn')), timeoutMs) @@ -67,17 +125,24 @@ try { bot.once('kicked', reason => reject(new Error(`Mineflayer was kicked: ${reason}`))) }) + bot.setSettings({ locale: 'zh_TW' }) + await bot.waitForTicks(10) + bot.chat('/dsp give') const debugStick = await waitFor( () => bot.inventory.items().find(item => item.name === 'blaze_rod'), 'DebugStickPro item' ) + await waitFor( + () => messages.some(message => message.includes('已將除錯棒給予')), + 'Traditional Chinese locale response' + ) await bot.equip(debugStick, 'hand') await bot.waitForTicks(20) bot.chat('/dsp mode freeze') await waitFor( - () => messages.some(message => /freeze/i.test(message)), + () => messages.some(message => /freeze|凍結/i.test(message)), 'freeze mode confirmation' ) await bot.waitForTicks(10) @@ -92,38 +157,25 @@ try { await bot.waitForTicks(10) await bot.lookAt(target.position.offset(0.5, 0.5, 0.5), true) - const packetOffset = entityPackets.length - await bot.activateBlock(target) - - const displays = await waitFor(() => { - const packets = entityPackets.slice(packetOffset) - const itemDisplay = packets.find(packet => - packet.name.includes('spawn_entity') && packet.type === bot.registry.entitiesByName.item_display.id - ) - const blockDisplay = packets.find(packet => - packet.name.includes('spawn_entity') && packet.type === bot.registry.entitiesByName.block_display.id - ) - return itemDisplay && blockDisplay ? { itemDisplay, blockDisplay } : null - }, 'VirtualEntities Item Display and Block Display') + await exerciseFreezeLifecycle(target, 'stone') - await waitFor( - () => { - const destroyPackets = entityPackets.slice(packetOffset) - .filter(packet => packet.name === 'entity_destroy') - const removed = entityId => destroyPackets.some(packet => - Array.from(packet.entityIds ?? []).includes(entityId) - ) - return removed(displays.itemDisplay.entityId) && removed(displays.blockDisplay.entityId) - }, - 'VirtualEntities removal' - ) + bot.chat(`/setblock ${targetPosition.x} ${targetPosition.y} ${targetPosition.z} minecraft:barrier`) + const barrier = await waitFor(() => { + const block = bot.blockAt(targetPosition) + return block?.name === 'barrier' ? block : null + }, 'integration-test barrier block') + await bot.waitForTicks(10) + await bot.lookAt(barrier.position.offset(0.5, 0.5, 0.5), true) + await exerciseFreezeLifecycle(barrier, 'barrier') console.log(JSON.stringify({ version, + locale: 'zh_TW', command: true, miniMessageItem: debugStick.customName?.toString() ?? debugStick.displayName, virtualEntities: ['item_display', 'block_display'], - removal: true + removal: true, + barrierRestoration: true })) } finally { bot.quit('DebugStickPro E2E complete') diff --git a/src/main/java/dev/twme/debugstickpro/DebugStickPro.java b/src/main/java/dev/twme/debugstickpro/DebugStickPro.java index 95429ee..dee0747 100644 --- a/src/main/java/dev/twme/debugstickpro/DebugStickPro.java +++ b/src/main/java/dev/twme/debugstickpro/DebugStickPro.java @@ -36,6 +36,7 @@ import dev.twme.debugstickpro.listeners.WorldUnloadEventListener; import dev.twme.debugstickpro.localization.LangFileManager; import dev.twme.debugstickpro.localization.PlayerLanguageManager; +import dev.twme.debugstickpro.localization.PlayerLocaleResolver; import dev.twme.debugstickpro.mode.freeze.FreezeBlockManager; import dev.twme.debugstickpro.mode.freeze.FreezePacketLayer; import dev.twme.debugstickpro.playerdata.PlayerData; @@ -154,7 +155,7 @@ public void onDisable() { */ public void onServerReloadCommand() { for (Player player : Bukkit.getOnlinePlayers()) { - PlayerLanguageManager.setPlayerLocale(player.getUniqueId(), player.getLocale()); + PlayerLanguageManager.setPlayerLocale(player.getUniqueId(), PlayerLocaleResolver.resolve(player)); UUID playerUUID = player.getUniqueId(); PlayerDataManager.setPlayerData(playerUUID, new PlayerData()); diff --git a/src/main/java/dev/twme/debugstickpro/listeners/PlayerJoinListener.java b/src/main/java/dev/twme/debugstickpro/listeners/PlayerJoinListener.java index bfca1cb..0255696 100644 --- a/src/main/java/dev/twme/debugstickpro/listeners/PlayerJoinListener.java +++ b/src/main/java/dev/twme/debugstickpro/listeners/PlayerJoinListener.java @@ -1,6 +1,7 @@ package dev.twme.debugstickpro.listeners; import dev.twme.debugstickpro.localization.PlayerLanguageManager; +import dev.twme.debugstickpro.localization.PlayerLocaleResolver; import dev.twme.debugstickpro.playerdata.PlayerData; import dev.twme.debugstickpro.playerdata.PlayerDataManager; import dev.twme.debugstickpro.utils.DebugStickItem; @@ -17,7 +18,7 @@ public class PlayerJoinListener implements Listener { public void onPlayerJoinEvent(PlayerJoinEvent event) { Player player = event.getPlayer(); - PlayerLanguageManager.setPlayerLocale(player.getUniqueId(), player.getLocale()); + PlayerLanguageManager.setPlayerLocale(player.getUniqueId(), PlayerLocaleResolver.resolve(player)); UUID playerUUID = event.getPlayer().getUniqueId(); PlayerDataManager.setPlayerData(playerUUID, new PlayerData()); diff --git a/src/main/java/dev/twme/debugstickpro/listeners/PlayerLocaleChangeEventListener.java b/src/main/java/dev/twme/debugstickpro/listeners/PlayerLocaleChangeEventListener.java index 6e469ac..19bfd85 100644 --- a/src/main/java/dev/twme/debugstickpro/listeners/PlayerLocaleChangeEventListener.java +++ b/src/main/java/dev/twme/debugstickpro/listeners/PlayerLocaleChangeEventListener.java @@ -1,7 +1,6 @@ package dev.twme.debugstickpro.listeners; import dev.twme.debugstickpro.localization.PlayerLanguageManager; -import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerLocaleChangeEvent; @@ -9,7 +8,6 @@ public class PlayerLocaleChangeEventListener implements Listener { @EventHandler public void onPlayerLocaleChangeEvent(PlayerLocaleChangeEvent event) { - Player player = event.getPlayer(); - PlayerLanguageManager.setPlayerLocale(player.getUniqueId(), player.getLocale()); + PlayerLanguageManager.setPlayerLocale(event.getPlayer().getUniqueId(), event.getLocale()); } } diff --git a/src/main/java/dev/twme/debugstickpro/listeners/PlayerQuitListener.java b/src/main/java/dev/twme/debugstickpro/listeners/PlayerQuitListener.java index ee6d3b8..de88ba8 100644 --- a/src/main/java/dev/twme/debugstickpro/listeners/PlayerQuitListener.java +++ b/src/main/java/dev/twme/debugstickpro/listeners/PlayerQuitListener.java @@ -1,5 +1,6 @@ package dev.twme.debugstickpro.listeners; +import dev.twme.debugstickpro.localization.PlayerLanguageManager; import dev.twme.debugstickpro.mode.freeze.FreezeBlockManager; import dev.twme.debugstickpro.playerdata.PlayerDataManager; import org.bukkit.event.EventHandler; @@ -14,6 +15,7 @@ public void onPlayerQuitEvent(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); PlayerDataManager.removePlayerFromDisplayList(uuid); PlayerDataManager.removePlayerData(uuid); + PlayerLanguageManager.removePlayerLocale(uuid); FreezeBlockManager.removeAllPlayerFrozenBlock(uuid); } } diff --git a/src/main/java/dev/twme/debugstickpro/localization/PlayerLanguageManager.java b/src/main/java/dev/twme/debugstickpro/localization/PlayerLanguageManager.java index 956ae5a..9ea4fdc 100644 --- a/src/main/java/dev/twme/debugstickpro/localization/PlayerLanguageManager.java +++ b/src/main/java/dev/twme/debugstickpro/localization/PlayerLanguageManager.java @@ -2,6 +2,7 @@ import dev.twme.debugstickpro.config.ConfigFile; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -33,6 +34,26 @@ public static String getLocale(UUID playerUUID) { * @param locale the locale of the player */ public static void setPlayerLocale(UUID playerUUID, String locale) { - playerLang.put(playerUUID, locale); + playerLang.put(playerUUID, normalizeLocale(locale)); + } + + public static void removePlayerLocale(UUID playerUUID) { + playerLang.remove(playerUUID); + } + + public static String normalizeLocale(String locale) { + if (locale == null || locale.isBlank()) { + return ConfigFile.Language.DefaultLanguage; + } + + String[] parts = locale.replace('-', '_').split("_", 3); + StringBuilder normalized = new StringBuilder(parts[0].toLowerCase(Locale.ROOT)); + if (parts.length >= 2 && !parts[1].isBlank()) { + normalized.append('_').append(parts[1].toUpperCase(Locale.ROOT)); + } + if (parts.length == 3 && !parts[2].isBlank()) { + normalized.append('_').append(parts[2]); + } + return normalized.toString(); } } diff --git a/src/main/java/dev/twme/debugstickpro/localization/PlayerLocaleResolver.java b/src/main/java/dev/twme/debugstickpro/localization/PlayerLocaleResolver.java new file mode 100644 index 0000000..4f84e71 --- /dev/null +++ b/src/main/java/dev/twme/debugstickpro/localization/PlayerLocaleResolver.java @@ -0,0 +1,26 @@ +package dev.twme.debugstickpro.localization; + +import org.bukkit.entity.Player; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Locale; + +public final class PlayerLocaleResolver { + private PlayerLocaleResolver() { + } + + public static String resolve(Player player) { + try { + Method localeMethod = Player.class.getMethod("locale"); + Object locale = localeMethod.invoke(player); + if (locale instanceof Locale paperLocale) { + return paperLocale.toString(); + } + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ignored) { + // Spigot does not expose Paper's Locale-returning API. + } + + return player.getLocale(); + } +} diff --git a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeBlockManager.java b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeBlockManager.java index 210a59c..c83d6c4 100644 --- a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeBlockManager.java +++ b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeBlockManager.java @@ -91,6 +91,7 @@ public static void removeOneBlock(UUID playerUUID, Block block) { freezeBlockLocations.remove(freezeLocation); freezeBlocks.remove(frozenData); + FreezePacketLayer.resyncBlock(block); if (freezeBlocks.isEmpty()) { playerFrozenBlockData.remove(playerUUID); } @@ -117,6 +118,7 @@ public static void removeAllPlayerFrozenBlock(UUID playerUUID) { removeDisplayEntity(frozenData.getItemDisplay()); removeDisplayEntity(frozenData.getBlockDisplay()); freezeBlockLocations.remove(new FreezeLocation(block.getLocation())); + FreezePacketLayer.resyncBlock(block); } } diff --git a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezePacketLayer.java b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezePacketLayer.java index 5da6496..d9a7f86 100644 --- a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezePacketLayer.java +++ b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezePacketLayer.java @@ -12,6 +12,7 @@ import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMultiBlockChange; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.block.Block; import org.bukkit.entity.Player; public final class FreezePacketLayer { @@ -43,6 +44,22 @@ public static synchronized void shutdown() { initialized = false; } + public static void resyncBlock(Block block) { + var playerManager = PacketEvents.getAPI().getPlayerManager(); + Vector3i position = new Vector3i(block.getX(), block.getY(), block.getZ()); + String blockData = block.getBlockData().getAsString(); + + for (Player player : block.getWorld().getPlayers()) { + WrappedBlockState state = WrappedBlockState.getByString( + playerManager.getClientVersion(player), + blockData + ); + if (state != null) { + playerManager.sendPacket(player, new WrapperPlayServerBlockChange(position, state)); + } + } + } + private static final class FreezePacketListener extends SimplePacketListenerAbstract { private FreezePacketListener() { super(PacketListenerPriority.NORMAL); diff --git a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeRightClick.java b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeRightClick.java index 0e57c36..0be2885 100644 --- a/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeRightClick.java +++ b/src/main/java/dev/twme/debugstickpro/mode/freeze/FreezeRightClick.java @@ -52,11 +52,17 @@ public static void onRightClick(UUID playerUUID, Action action, Block clickedBlo } private static Block resolveTargetBlock(Player player, Action action, Block clickedBlock, BlockFace clickedFace) { - if (action == Action.RIGHT_CLICK_BLOCK && clickedBlock != null) { - if (FreezeBlockManager.isFreezeBlock(clickedBlock.getLocation())) { - return clickedBlock; - } + Block frozenClickedTarget = resolveFrozenClickedTarget(clickedBlock, clickedFace); + if (frozenClickedTarget != null) { + return frozenClickedTarget; + } + Block frozenSightTarget = resolveFrozenSightTarget(player, clickedBlock); + if (frozenSightTarget != null) { + return frozenSightTarget; + } + + if (action == Action.RIGHT_CLICK_BLOCK && clickedBlock != null) { Block supportBlock = resolveFrozenSupportBlock(clickedBlock, clickedFace); if (supportBlock != null) { return supportBlock; @@ -72,6 +78,35 @@ private static Block resolveTargetBlock(Player player, Action action, Block clic return null; } + private static Block resolveFrozenClickedTarget(Block clickedBlock, BlockFace clickedFace) { + if (clickedBlock == null) { + return null; + } + if (FreezeBlockManager.isFreezeBlock(clickedBlock.getLocation())) { + return clickedBlock; + } + if (clickedFace == null || clickedFace == BlockFace.SELF) { + return null; + } + + Block blockInFront = clickedBlock.getRelative(clickedFace); + return FreezeBlockManager.isFreezeBlock(blockInFront.getLocation()) ? blockInFront : null; + } + + private static Block resolveFrozenSightTarget(Player player, Block clickedBlock) { + BlockIterator sightLine = new BlockIterator(player, TARGET_DISTANCE); + while (sightLine.hasNext()) { + Block block = sightLine.next(); + if (FreezeBlockManager.isFreezeBlock(block.getLocation())) { + return block; + } + if (clickedBlock != null && clickedBlock.equals(block)) { + return null; + } + } + return null; + } + private static Block resolveAirTarget(Player player) { Block normalTarget = player.getTargetBlockExact(TARGET_DISTANCE); // BlockIterator also visits air cells, so display-only frozen blocks remain targetable. diff --git a/src/test/java/dev/twme/debugstickpro/localization/PlayerLanguageManagerTest.java b/src/test/java/dev/twme/debugstickpro/localization/PlayerLanguageManagerTest.java new file mode 100644 index 0000000..795263b --- /dev/null +++ b/src/test/java/dev/twme/debugstickpro/localization/PlayerLanguageManagerTest.java @@ -0,0 +1,22 @@ +package dev.twme.debugstickpro.localization; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class PlayerLanguageManagerTest { + @Test + public void normalizesPaperLanguageTag() { + assertEquals("zh_TW", PlayerLanguageManager.normalizeLocale("zh-TW")); + } + + @Test + public void normalizesSpigotLocale() { + assertEquals("en_US", PlayerLanguageManager.normalizeLocale("EN_us")); + } + + @Test + public void preservesLocaleVariant() { + assertEquals("zh_HANT_TW", PlayerLanguageManager.normalizeLocale("zh-Hant-TW")); + } +}