diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseManager.java b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java index 3836da0..faf3b0a 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseManager.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseManager.java @@ -187,6 +187,10 @@ final class PocketBaseManager { return worlds.ownerForWorld(worldId); } + Optional availablePocketArrival(UUID ownerId) { + return state(ownerId).level() > 0 ? worlds.availableArrival(ownerId) : Optional.empty(); + } + Location pocketArrival(UUID ownerId) { return worlds.arrival(ownerId); } diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java b/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java new file mode 100644 index 0000000..077b74b --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java @@ -0,0 +1,298 @@ +package games.dmg.spigotbase; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.IntSupplier; +import net.kyori.adventure.text.Component; +import org.bukkit.Keyed; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.persistence.PersistentDataType; + +/** Destination-bound guest access, separate from holder-bound Keystones. */ +final class PocketBaseSharingCompass implements Listener { + private final Server server; + private final PocketBaseManager pockets; + private final NamespacedKey key; + private final NamespacedKey rechargeKey; + private final NamespacedKey ownerKey; + private final NamespacedKey chargesKey; + private final NamespacedKey identityKey; + private final NamespacedKey ownerNameKey; + private final IntSupplier roll; + private final Set travelling = new HashSet<>(); + + PocketBaseSharingCompass(Server server, PocketBaseManager pockets, NamespacedKey key, IntSupplier roll) { + this.server = server; + this.pockets = pockets; + this.key = key; + this.rechargeKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_recharge"); + this.ownerKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner"); + this.chargesKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_charges"); + this.identityKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_id"); + this.ownerNameKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner_name"); + this.roll = roll; + } + + void registerRecipes() { + var creation = new ShapedRecipe(key, new ItemStack(Material.COMPASS)); + creation.shape("DED", "ECE", "DED"); + creation.setIngredient('D', Material.DIAMOND_BLOCK); + creation.setIngredient('E', Material.ECHO_SHARD); + creation.setIngredient('C', Material.END_CRYSTAL); + server.addRecipe(creation); + var recharge = new ShapedRecipe(rechargeKey, new ItemStack(Material.COMPASS)); + recharge.shape("PPP", "PCP", "PPP"); + recharge.setIngredient('P', Material.ENDER_PEARL); + recharge.setIngredient('C', Material.COMPASS); + server.addRecipe(recharge); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPrepare(PrepareItemCraftEvent event) { + if (!(event.getView().getPlayer() instanceof Player player) + || !ourRecipe(event.getRecipe()) || empty(event.getInventory().getResult())) return; + // Only our candidate matrices/results are replaced; unrelated recipes remain untouched. + ItemStack[] matrix = event.getInventory().getMatrix(); + if (rechargeCandidate(matrix)) { + event.getInventory().setResult(rechargeAllowed(matrix, player, event.getInventory().getLocation()) + ? withCharges(matrix[4], state(matrix[4]).charges() + 8) : null); + return; + } + if (matrix.length != 9 || !material(matrix[4], Material.END_CRYSTAL)) return; + event.getInventory().setResult(creationMatrix(matrix) && pockets.state(player.getUniqueId()).level() > 0 + ? create(player.getUniqueId(), player.getName()) : null); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onCraft(CraftItemEvent event) { + if (!ourRecipe(event.getRecipe())) return; + event.setCancelled(true); + ItemStack[] matrix = event.getInventory().getMatrix(); + boolean creation = creationMatrix(matrix); + boolean recharge = rechargeCandidate(matrix); + if (!creation && !recharge) { + event.getInventory().setResult(null); + return; + } + if (empty(event.getInventory().getResult()) || !(event.getWhoClicked() instanceof Player player) + || event.getRawSlot() != 0 || event.getView().getType() != InventoryType.WORKBENCH) return; + if (creation ? pockets.state(player.getUniqueId()).level() < 1 + : !rechargeAllowed(matrix, player, event.getInventory().getLocation())) return; + boolean shift = event.isShiftClick(); + if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT + && event.getClick() != ClickType.SHIFT_LEFT && event.getClick() != ClickType.SHIFT_RIGHT) return; + int slot = shift ? player.getInventory().firstEmpty() : -1; + if (shift ? slot < 0 : !empty(event.getCursor())) return; + ItemStack output; + if (creation) output = create(player.getUniqueId(), player.getName()); + else { + long proposed = state(matrix[4]).charges() + 8; + output = roll.getAsInt() < successChance(proposed) ? withCharges(matrix[4], proposed) : null; + } + for (int i = 0; i < matrix.length; i++) { + matrix[i].setAmount(matrix[i].getAmount() - 1); + if (matrix[i].getAmount() == 0) matrix[i] = null; + } + event.getInventory().setResult(null); + event.getInventory().setMatrix(matrix); + if (output == null) { + Location table = event.getInventory().getLocation(); + // Native damage/block destruction and protection events, not a cosmetic effect. + // Consume first: an explosion may synchronously kill the crafter or close the menu. + Location center = new Location(table.getWorld(), table.getBlockX() + 0.5, + table.getBlockY() + 0.5, table.getBlockZ() + 0.5); + // The shorter overload excludes its source from damage; the crafter must remain eligible. + table.getWorld().createExplosion(player, center, 6.0F, false, true, false); + } else if (shift) player.getInventory().setItem(slot, output); + else event.getView().setCursor(output); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onUse(PlayerInteractEvent event) { + if ((event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) + || event.useItemInHand() == Event.Result.DENY || event.getHand() == null) return; + Player player = event.getPlayer(); + ItemStack item = player.getInventory().getItem(event.getHand()); + State state = state(item); + if (state == null || !item.equals(event.getItem())) return; + event.setCancelled(true); + if (state.charges() == 0 || pockets.state(state.owner()).level() < 1 + || pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()) return; + var destination = pockets.availablePocketArrival(state.owner()); + if (destination.isEmpty() || !travelling.add(player.getUniqueId())) return; + float fallDistance = player.getFallDistance(); + boolean teleported = false; + try { + player.setFallDistance(0F); + teleported = player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN); + if (teleported && !player.isDead() && player.getWorld().equals(destination.orElseThrow().getWorld())) { + spendCarriedCharge(player, state.identity()); + } + } finally { + if (!teleported) player.setFallDistance(fallDistance); + travelling.remove(player.getUniqueId()); + } + } + + private void spendCarriedCharge(Player player, UUID identity) { + // Teleport listeners may move/remove the item, change the held slot, or trigger death. + // Never reinstall the pre-teleport snapshot over a replacement item or resurrect a drop. + ItemStack[] items = player.getInventory().getContents(); + for (int slot = 0; slot < items.length; slot++) { + State current = state(items[slot]); + if (current != null && current.identity().equals(identity)) { + if (current.charges() > 0) player.getInventory().setItem(slot, withCharges(items[slot], current.charges() - 1)); + return; + } + } + ItemStack cursor = player.getItemOnCursor(); + State current = state(cursor); + if (current != null && current.identity().equals(identity) && current.charges() > 0) { + player.setItemOnCursor(withCharges(cursor, current.charges() - 1)); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDeath(PlayerDeathEvent event) { + Player player = event.getEntity(); + ItemStack[] carried = player.getInventory().getContents(); + for (int i = 0; i < carried.length; i++) { + if (state(carried[i]) != null) player.getInventory().setItem(i, withCharges(carried[i], 0)); + } + if (state(player.getItemOnCursor()) != null) player.setItemOnCursor(withCharges(player.getItemOnCursor(), 0)); + discharge(event.getDrops()); + discharge(event.getItemsToKeep()); + } + + private void discharge(List items) { + for (int i = 0; i < items.size(); i++) { + if (state(items.get(i)) != null) items.set(i, withCharges(items.get(i), 0)); + } + } + + private boolean ourRecipe(Recipe recipe) { + return recipe instanceof Keyed keyed + && (key.equals(keyed.getKey()) || rechargeKey.equals(keyed.getKey())); + } + + private static int successChance(long proposed) { + return proposed >= 73 ? 10 : 100 - 10 * (int) ((proposed - 1) / 8); + } + + private boolean rechargeCandidate(ItemStack[] matrix) { + if (matrix.length != 9 || !material(matrix[4], Material.COMPASS)) return false; + // Do not hijack ordinary map/recovery-compass recipes. Marked items still fail + // closed when their metadata or surrounding ingredients are malformed. + if (matrix[4].getItemMeta().getPersistentDataContainer().has(key)) return true; + for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false; + return true; + } + + private boolean rechargeAllowed(ItemStack[] matrix, Player player, Location table) { + State state = matrix.length == 9 ? state(matrix[4]) : null; + if (state == null || state.charges() > Long.MAX_VALUE - 8 || table == null || table.getWorld() == null + || !table.getWorld().equals(player.getWorld()) + || table.getBlock().getType() != Material.CRAFTING_TABLE + || !pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent() + || pockets.state(state.owner()).level() < 1) return false; + for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false; + return true; + } + + private State state(ItemStack item) { + if (!material(item, Material.COMPASS) || item.getAmount() != 1) return null; + var meta = item.getItemMeta(); + if (meta == null) return null; + var data = meta.getPersistentDataContainer(); + try { + Byte marker = data.get(key, PersistentDataType.BYTE); + String owner = data.get(ownerKey, PersistentDataType.STRING); + Long charges = data.get(chargesKey, PersistentDataType.LONG); + String identity = data.get(identityKey, PersistentDataType.STRING); + if (marker == null || marker != 1 || owner == null || identity == null + || charges == null || charges < 0) return null; + UUID ownerId = UUID.fromString(owner); + UUID itemId = UUID.fromString(identity); + if (!ownerId.toString().equals(owner) || !itemId.toString().equals(identity)) return null; + return new State(ownerId, charges, itemId); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private ItemStack withCharges(ItemStack item, long charges) { + var updated = item.clone(); + var meta = updated.getItemMeta(); + var data = meta.getPersistentDataContainer(); + data.set(chargesKey, PersistentDataType.LONG, charges); + String owner = data.get(ownerKey, PersistentDataType.STRING); + String name = data.getOrDefault(ownerNameKey, PersistentDataType.STRING, owner); + String risk = charges > Long.MAX_VALUE - 8 ? "Next recharge: unavailable (numeric overflow)" + : "Next recharge: " + successChance(charges + 8) + "% success / " + + (100 - successChance(charges + 8)) + "% explosion"; + meta.lore(List.of( + Component.text("Destination: " + name + " (" + owner + ")"), + Component.text("Charges: " + charges), + Component.text("Right-click for entry only; no return trip."), + Component.text("Recharge: center of table + 8 ender pearls, inside destination."), + Component.text(risk) + )); + updated.setItemMeta(meta); + return updated; + } + + private record State(UUID owner, long charges, UUID identity) {} + + private static boolean empty(ItemStack item) { + return item == null || item.getType().isAir() || item.getAmount() == 0; + } + + private static boolean material(ItemStack item, Material material) { + return item != null && item.getAmount() > 0 && item.getType() == material; + } + + private static boolean creationMatrix(ItemStack[] matrix) { + if (matrix.length != 9) return false; + for (int i = 0; i < 9; i++) { + if (!material(matrix[i], i == 4 ? Material.END_CRYSTAL + : i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD)) return false; + } + return true; + } + + private ItemStack create(UUID owner, String name) { + ItemStack item = new ItemStack(Material.COMPASS); + var meta = item.getItemMeta(); + var data = meta.getPersistentDataContainer(); + data.set(key, PersistentDataType.BYTE, (byte) 1); + data.set(ownerKey, PersistentDataType.STRING, owner.toString()); + data.set(ownerNameKey, PersistentDataType.STRING, name); + data.set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString()); + data.set(chargesKey, PersistentDataType.LONG, 0L); + meta.displayName(Component.text("Pocket Base Sharing Compass")); + meta.setMaxStackSize(1); + item.setItemMeta(meta); + return withCharges(item, 0); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java b/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java index a43b17e..2a6d034 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseWorldService.java @@ -109,6 +109,13 @@ final class PocketBaseWorldService { world.save(); } + Optional availableArrival(UUID ownerId) { + // A guest item must not recreate a missing world or grant an unlock. + World world = server.getWorld(worldName(ownerId)); + return world == null ? Optional.empty() + : Optional.of(new Location(world, 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f)); + } + Location arrival(UUID ownerId) { return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f); } diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 0f5f699..898bf53 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -18,6 +18,7 @@ public final class SpigotBasePlugin extends JavaPlugin { private PocketBaseManager pocketBaseManager; private PocketBaseController pocketBaseController; private PocketBaseKeystoneService keystoneService; + private PocketBaseSharingCompass sharingCompass; private BaseIdentificationController identificationController; @Override @@ -54,6 +55,10 @@ public final class SpigotBasePlugin extends JavaPlugin { getLogger() ); keystoneService.registerRecipe(); + sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager, + new NamespacedKey(this, "sharing_compass"), + () -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100)); + sharingCompass.registerRecipes(); } catch (RuntimeException | IOException exception) { getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception); getServer().getPluginManager().disablePlugin(this); @@ -112,6 +117,7 @@ public final class SpigotBasePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(teleportManager, this); getServer().getPluginManager().registerEvents(pocketBaseController, this); getServer().getPluginManager().registerEvents(keystoneService, this); + getServer().getPluginManager().registerEvents(sharingCompass, this); command("setbase").setExecutor(new SetBaseCommand( stateManager, baseService, Clock.systemUTC(), pocketBaseController diff --git a/src/nativeTest/java/games/dmg/spigotbase/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotbase/NativeRuntime.java index 2bc7003..653ea0b 100644 --- a/src/nativeTest/java/games/dmg/spigotbase/NativeRuntime.java +++ b/src/nativeTest/java/games/dmg/spigotbase/NativeRuntime.java @@ -27,9 +27,13 @@ import org.bukkit.craftbukkit.CraftRegistry; /** Loads vanilla registries/tags/components using the same path as the server's own tests. */ final class NativeRuntime { + private static boolean initialized; + private NativeRuntime() {} - static void bootstrap() throws Exception { + static synchronized void bootstrap() throws Exception { + // Paper registries are process-global and reject loading a second time. + if (initialized) return; SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); var flags = FeatureFlags.VANILLA_SET; @@ -54,5 +58,6 @@ final class NativeRuntime { datapack.updateComponentsAndStaticRegistryTags(); CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze()); } + initialized = true; } } diff --git a/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java b/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java new file mode 100644 index 0000000..dfaaaa5 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java @@ -0,0 +1,825 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.IntSupplier; +import org.bukkit.*; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.inventory.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredListener; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class SharingCompassEventsTest { + static final NamespacedKey KEY = new NamespacedKey("spigotbase", "sharing_compass"); + final UUID owner = UUID.randomUUID(); + final Server server = mock(Server.class); + final Player player = mock(Player.class); + final World world = mock(World.class); + final PocketBaseManager pockets = mock(PocketBaseManager.class); + final CraftingInventory inventory = mock(CraftingInventory.class); + final InventoryView view = mock(InventoryView.class); + final IntSupplier roll = mock(IntSupplier.class); + ItemStack[] matrix = new ItemStack[9]; + ItemStack result; + ItemStack cursor; + final PlayerInventory carried = mock(PlayerInventory.class); + final ItemStack[] contents = new ItemStack[41]; + MockedStatic bukkit; + PocketBaseSharingCompass service; + + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + @BeforeEach void setup() { + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance()); + bukkit.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE); + when(player.getUniqueId()).thenReturn(owner); + when(player.getName()).thenReturn("Alice"); + when(player.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(view.getPlayer()).thenReturn(player); + when(view.getTopInventory()).thenReturn(inventory); + when(view.getType()).thenReturn(InventoryType.WORKBENCH); + when(view.getCursor()).thenAnswer(inv -> cursor); + doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(view).setCursor(any()); + when(player.getInventory()).thenReturn(carried); + when(carried.getContents()).thenAnswer(inv -> contents.clone()); + when(player.getItemOnCursor()).thenAnswer(inv -> cursor); + doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(player).setItemOnCursor(any()); + when(carried.getItem(any(EquipmentSlot.class))).thenAnswer(inv -> contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40]); + doAnswer(inv -> { contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40] = inv.getArgument(1); return null; }).when(carried).setItem(any(EquipmentSlot.class), any()); + when(carried.firstEmpty()).thenAnswer(inv -> { for (int i = 0; i < 36; i++) if (contents[i] == null) return i; return -1; }); + doAnswer(inv -> { contents[(int) inv.getArgument(0)] = inv.getArgument(1); return null; }).when(carried).setItem(anyInt(), any()); + when(inventory.getMatrix()).thenAnswer(inv -> Arrays.stream(matrix).map(i -> i == null ? null : i.clone()).toArray(ItemStack[]::new)); + doAnswer(inv -> { matrix = inv.getArgument(0); return null; }).when(inventory).setMatrix(any(ItemStack[].class)); + when(inventory.getRecipe()).thenAnswer(inv -> candidateRecipe()); + when(inventory.getResult()).thenAnswer(inv -> result); + doAnswer(inv -> { result = inv.getArgument(0); return null; }).when(inventory).setResult(any()); + when(pockets.state(owner)).thenReturn(new PocketBaseState(owner, 1, Optional.empty())); + service = new PocketBaseSharingCompass(server, pockets, KEY, roll); + } + @AfterEach void close() { bukkit.close(); } + + @Test void registersTheExactCreationRecipe() { + service.registerRecipes(); + var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class); + verify(server, atLeastOnce()).addRecipe(recipes.capture()); + var recipe = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe shaped && shaped.getKey().equals(KEY)).findFirst().orElseThrow(); + var shaped = (ShapedRecipe) recipe; + assertArrayEquals(new String[]{"DED", "ECE", "DED"}, shaped.getShape()); + assertEquals(Material.DIAMOND_BLOCK, shaped.getIngredientMap().get('D').getType()); + assertEquals(Material.ECHO_SHARD, shaped.getIngredientMap().get('E').getType()); + assertEquals(Material.END_CRYSTAL, shaped.getIngredientMap().get('C').getType()); + } + + static NamespacedKey field(String suffix) { return new NamespacedKey("spigotbase", "sharing_compass_" + suffix); } + static long charges(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(field("charges"), PersistentDataType.LONG); } + ItemStack compass(long charges) throws Exception { + cursor = null; + creationMatrix(); + dispatch(take(ClickType.LEFT)); + var item = cursor.clone(); + cursor = null; + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(field("charges"), PersistentDataType.LONG, charges); + item.setItemMeta(meta); + return item; + } + void rechargeMatrix(ItemStack item) { + for (int i = 0; i < 9; i++) matrix[i] = i == 4 ? item : new ItemStack(Material.ENDER_PEARL, 16); + when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner)); + when(inventory.getLocation()).thenReturn(new Location(world, 10, 65, -9)); + var table = mock(org.bukkit.block.Block.class); + when(table.getType()).thenReturn(Material.CRAFTING_TABLE); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(table); + when(world.getBlockAt(any(Location.class))).thenReturn(table); + } + + @Test void safeRechargePreviewAndCommitRequireExactPearlsAndBoundTableWorld() throws Exception { + var item = compass(0); + rechargeMatrix(item); + preview(); + assertNotNull(result, "Recharge must have a result preview"); + verifyNoInteractions(roll); + assertEquals(0, charges(matrix[4])); + assertEquals(16, matrix[0].getAmount()); + when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(UUID.randomUUID())); + preview(); + assertNull(result); + dispatch(take(ClickType.LEFT)); + assertNull(cursor); + when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner)); + matrix[0] = new ItemStack(Material.SNOWBALL); + preview(); + assertNull(result); + dispatch(take(ClickType.LEFT)); + assertNull(cursor); + matrix[0] = new ItemStack(Material.ENDER_PEARL, 16); + preview(); + dispatch(take(ClickType.LEFT)); + assertNotNull(cursor); + assertEquals(8, charges(cursor)); + assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING), + cursor.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING)); + assertNull(matrix[4]); + for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount()); + verify(roll).getAsInt(); + verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + static java.util.stream.Stream rechargeBoundaries() { + var cases = new ArrayList(); + long[] totals = {8, 9, 16, 17, 24, 25, 32, 33, 40, 41, 48, 49, 56, 57, 64, 65, 72, 73, 80, 81, 1024, Long.MAX_VALUE}; + for (long total : totals) { + int chance = total >= 73 ? 10 : 100 - 10 * (int) ((total - 1) / 8); + cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance - 1, true)); + if (chance < 100) cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance, false)); + } + return cases.stream(); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("rechargeBoundaries") + void eachCommittedRechargeRollsAtCurrentChargeBoundaryAndDestroysOnFailure(long current, int random, boolean succeeds) throws Exception { + rechargeMatrix(compass(current)); + for (int n = 0; n < 3; n++) preview(); + verifyNoInteractions(roll); + when(roll.getAsInt()).thenReturn(random); + // A denied native explosion is not retried or replaced and never refunds the failed transaction. + when(world.createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean())).thenReturn(false); + var event = take(ClickType.LEFT); + dispatch(event); + assertTrue(event.isCancelled()); + verify(roll).getAsInt(); + assertNull(matrix[4], "The input compass leaves the grid on either outcome"); + for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount()); + if (succeeds) { + assertNotNull(cursor); + assertEquals(current + 8, charges(cursor)); + verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } else { + assertNull(cursor, "Failed recharge must destroy the compass, not produce charges"); + verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6.0F, false, true, false); + } + dispatch(event); + dispatch(take(ClickType.SHIFT_LEFT)); + verify(roll).getAsInt(); + if (!succeeds) verify(world, times(1)).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + @Test void rechargeHasRegisteredTableRecipe() { + service.registerRecipes(); + var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class); + verify(server, times(2)).addRecipe(recipes.capture()); + var shaped = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe s && !s.getKey().equals(KEY)) + .map(ShapedRecipe.class::cast).findFirst().orElseThrow(); + assertArrayEquals(new String[]{"PPP", "PCP", "PPP"}, shaped.getShape()); + assertEquals(Material.ENDER_PEARL, shaped.getIngredientMap().get('P').getType()); + assertEquals(Material.COMPASS, shaped.getIngredientMap().get('C').getType()); + } + + void successfulTeleport(Location destination) { + when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> { + when(player.getWorld()).thenReturn(destination.getWorld()); + return true; + }); + } + + PlayerInteractEvent use(Action action, EquipmentSlot hand) { + return new PlayerInteractEvent(player, action, carried.getItem(hand), null, org.bukkit.block.BlockFace.SELF, hand); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) + void transferredCompassEntersBoundOwnerOnlyConsumesAfterSuccessAndNeverReturns(Action action) throws Exception { + var item = compass(2); + UUID guest = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(guest); + when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest)); + var destinationWorld = mock(World.class); + when(destinationWorld.getUID()).thenReturn(UUID.randomUUID()); + when(pockets.ownerForPocketWorld(destinationWorld.getUID())).thenReturn(Optional.of(owner)); + var destination = new Location(destinationWorld, .5, 65, 5.5); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination)); + contents[0] = item; + when(player.getFallDistance()).thenReturn(12F); + // Teleport's native result represents the outcome after PlayerTeleportEvent protection handling. + when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> { + assertEquals(2, charges(contents[0]), "Charge cannot be spent before successful teleport"); + var teleport = new PlayerTeleportEvent(player, new Location(world, 0, 70, 0), destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + teleport.setCancelled(true); + return !teleport.isCancelled(); + }); + dispatch(use(action, EquipmentSlot.HAND)); + assertEquals(2, charges(contents[0])); + verify(player).setFallDistance(12F); + successfulTeleport(destination); + dispatch(use(action, EquipmentSlot.HAND)); + assertEquals(1, charges(contents[0]), "Guest travel must spend exactly one charge"); + assertEquals(owner.toString(), contents[0].getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING)); + when(player.getWorld()).thenReturn(destinationWorld); + dispatch(use(action, EquipmentSlot.HAND)); + assertEquals(1, charges(contents[0])); + verify(player, times(2)).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + } + + @Test void unavailableEmptyAndDeniedTravelNeverSpendAndOffHandCanEnter() throws Exception { + contents[40] = compass(1); + var destination = new Location(mock(World.class), 0, 65, 0); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.empty()); + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND)); + assertEquals(1, charges(contents[40])); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination)); + var denied = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND); + denied.setUseItemInHand(Event.Result.DENY); + dispatch(denied); + assertEquals(1, charges(contents[40])); + verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)); + successfulTeleport(destination); + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND)); + assertEquals(0, charges(contents[40])); + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND)); + verify(player).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + when(player.getWorld()).thenReturn(world); + rechargeMatrix(contents[40]); + when(roll.getAsInt()).thenReturn(99); + dispatch(take(ClickType.LEFT)); + assertEquals(8, charges(cursor), "Spending back to zero makes recharge 100% safe"); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"inventory", "cursor", "removed", "death", "redirect", "reentrant"}) + void teleportCallbacksCannotDuplicateResurrectOrOverwriteItems(String callback) throws Exception { + var item = compass(8); + contents[0] = item; + var replacement = new ItemStack(Material.DIAMOND, 7); + var destinationWorld = mock(World.class); + var destination = new Location(destinationWorld, 0, 65, 0); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination)); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var drops = new ArrayList(); + when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> { + if (calls.incrementAndGet() == 1) { + switch (callback) { + case "inventory" -> { contents[12] = contents[0]; contents[0] = replacement; } + case "cursor" -> { cursor = contents[0]; contents[0] = replacement; } + case "removed" -> contents[0] = replacement; + case "death" -> { + drops.add(contents[0].clone()); + dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class), + drops, 0, net.kyori.adventure.text.Component.text("death"), true)); + contents[0] = null; // Native death clears the inventory after the event. + when(player.isDead()).thenReturn(true); + } + case "redirect" -> { /* Protection redirects travel outside the bound Pocket Base. */ } + case "reentrant" -> dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND)); + default -> throw new AssertionError(callback); + } + } + if (!callback.equals("redirect")) when(player.getWorld()).thenReturn(destinationWorld); + return true; + }); + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND)); + switch (callback) { + case "inventory" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(contents[12])); } + case "cursor" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(cursor)); } + case "removed" -> assertEquals(replacement, contents[0], "Never recreate a removed compass over another item"); + case "death" -> { assertNull(contents[0], "Never resurrect a discharged/dropped compass"); assertEquals(0, charges(drops.getFirst())); } + case "redirect" -> assertEquals(8, charges(contents[0]), "No bound-destination entry means no charge spent"); + case "reentrant" -> { assertEquals(1, calls.get(), "A teleport callback must not reuse the pending item"); assertEquals(7, charges(contents[0])); } + default -> throw new AssertionError(callback); + } + } + + @Test void visibleInstructionsAndRiskRefreshWithChargesAndKeepOwnerIdentity() throws Exception { + var item = compass(0); + var lore = item.getItemMeta().lore(); + assertNotNull(lore); + assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Charges: 0")), "Current charges must be visible"); + assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Destination: Alice (" + owner + ")"))); + assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Recharge: center of table + 8 ender pearls, inside destination."))); + assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Next recharge: 100% success / 0% explosion"))); + assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Right-click for entry only; no return trip."))); + rechargeMatrix(item); + dispatch(take(ClickType.LEFT)); + assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 8"))); + assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Next recharge: 90% success / 10% explosion"))); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void deathDischargesCarriedDroppedAndKeptCompassesWithoutChangingNormalDeath(boolean keepInventory) throws Exception { + var item = compass(80); + contents[0] = item.clone(); + contents[40] = item.clone(); + contents[12] = new ItemStack(Material.DIAMOND, 12); + cursor = item.clone(); + var drops = new ArrayList(List.of(item.clone(), new ItemStack(Material.DIAMOND, 12))); + var death = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class), + drops, 17, net.kyori.adventure.text.Component.text("ordinary death"), true); + death.setKeepInventory(keepInventory); + death.getItemsToKeep().add(item.clone()); + dispatch(death); + assertEquals(0, charges(contents[0]), "Death empties carried compasses"); + assertEquals(0, charges(contents[40])); + assertEquals(0, charges(cursor)); + assertEquals(0, charges(drops.getFirst())); + assertEquals(0, charges(death.getItemsToKeep().getFirst())); + assertEquals(owner.toString(), drops.getFirst().getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING)); + assertEquals(keepInventory, death.getKeepInventory()); + assertEquals(2, drops.size()); + assertEquals(12, drops.get(1).getAmount()); + assertEquals(12, contents[12].getAmount()); + assertEquals(17, death.getDroppedExp()); + assertFalse(death.isCancelled()); + assertTrue(drops.getFirst().getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 0"))); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"missing-id", "bad-id", "short-owner", "bad-owner", "negative", "wrong-charge-type", "missing-charges", "wrong-marker-type", "wrong-version", "stacked", "name-only"}) + void malformedItemsCannotTravelOrRecharge(String corruption) throws Exception { + var item = compass(5); + var meta = item.getItemMeta(); + var data = meta.getPersistentDataContainer(); + switch (corruption) { + case "missing-id" -> data.remove(field("id")); + case "bad-id" -> data.set(field("id"), PersistentDataType.STRING, "broken"); + case "short-owner" -> data.set(field("owner"), PersistentDataType.STRING, "1-1-1-1-1"); + case "bad-owner" -> data.set(field("owner"), PersistentDataType.STRING, "broken"); + case "negative" -> data.set(field("charges"), PersistentDataType.LONG, -1L); + case "wrong-charge-type" -> data.set(field("charges"), PersistentDataType.STRING, "5"); + case "missing-charges" -> data.remove(field("charges")); + case "wrong-marker-type" -> data.set(KEY, PersistentDataType.INTEGER, 1); + case "wrong-version" -> data.set(KEY, PersistentDataType.BYTE, (byte) 2); + case "stacked" -> item.setAmount(2); + case "name-only" -> { for (var key : new HashSet<>(data.getKeys())) data.remove(key); } + default -> throw new AssertionError(corruption); + } + item.setItemMeta(meta); + var destination = new Location(mock(World.class), 0, 65, 0); + when(pockets.availablePocketArrival(any())).thenReturn(Optional.of(destination)); + when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenReturn(true); + contents[0] = item; + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND)); + verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)); + rechargeMatrix(item); + preview(); + assertNull(result); + dispatch(take(ClickType.LEFT)); + assertNull(cursor); + verifyNoInteractions(roll); + assertEquals(16, matrix[0].getAmount()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 7}) + void numericOverflowRefusesRechargeBeforeRollingOrPaying(long charges) throws Exception { + rechargeMatrix(compass(charges)); + preview(); + assertNull(result, "Overflow is invalid arithmetic, not a wrapped free recharge"); + dispatch(take(ClickType.SHIFT_LEFT)); + assertNull(contents[0]); + assertEquals(charges, charges(matrix[4])); + assertEquals(16, matrix[0].getAmount()); + verifyNoInteractions(roll); + } + + @Test void nativeItemPersistenceAcrossFreshServiceAndGuestTransferKeepsSingleIdentityAndCharges() throws Exception { + var item = compass(1024); + byte[] saved = item.serializeAsBytes(); + var restored = ItemStack.deserializeBytes(saved); + assertEquals(item, restored); + assertEquals(1, restored.getMaxStackSize()); + service = new PocketBaseSharingCompass(server, pockets, KEY, roll); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + var destination = new Location(mock(World.class), 0, 65, 0); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination)); + successfulTeleport(destination); + contents[0] = restored; + dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND)); + assertEquals(1023, charges(contents[0])); + var afterLogout = ItemStack.deserializeBytes(contents[0].serializeAsBytes()); + assertEquals(1023, charges(afterLogout)); + assertEquals(owner.toString(), afterLogout.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING)); + assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING), + afterLogout.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING)); + assertEquals(1, Arrays.stream(contents).filter(Objects::nonNull).count()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"LEFT", "RIGHT", "SHIFT_LEFT", "SHIFT_RIGHT"}) + void eachResultTakingModePaysForOneRechargeAndNeverDuplicatesDestroyedResults(ClickType click) throws Exception { + rechargeMatrix(compass(0)); + when(roll.getAsInt()).thenReturn(0, 99); + var first = take(click); + dispatch(first); + var charged = click.isShiftClick() ? contents[0] : cursor; + assertNotNull(charged); + assertEquals(8, charges(charged)); + dispatch(first); + verify(roll).getAsInt(); + contents[0] = null; + cursor = null; + matrix[4] = charged; + for (int i = 0; i < 5; i++) preview(); + verify(roll).getAsInt(); + var second = take(click); + dispatch(second); + dispatch(second); + dispatch(take(click)); + assertNull(matrix[4]); + assertNull(cursor); + assertTrue(Arrays.stream(contents).allMatch(Objects::isNull)); + for (int i = 0; i < 9; i++) if (i != 4) assertEquals(14, matrix[i].getAmount()); + verify(roll, times(2)).getAsInt(); + verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6F, false, true, false); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"NUMBER_KEY", "SWAP_OFFHAND", "DROP", "CONTROL_DROP", "DOUBLE_CLICK", "MIDDLE"}) + void unsupportedBulkActionsCannotBypassPayment(ClickType click) throws Exception { + rechargeMatrix(compass(8)); + dispatch(take(click)); + assertEquals(8, charges(matrix[4])); + assertEquals(16, matrix[0].getAmount()); + assertNull(cursor); + verifyNoInteractions(roll); + } + + @Test void cancelledCraftsAndFullDestinationsDoNotRollOrPay() throws Exception { + rechargeMatrix(compass(8)); + var cancelled = take(ClickType.LEFT); + cancelled.setCancelled(true); + dispatch(cancelled); + cursor = new ItemStack(Material.STONE); + dispatch(take(ClickType.LEFT)); + cursor = null; + Arrays.fill(contents, new ItemStack(Material.STONE, 64)); + dispatch(take(ClickType.SHIFT_LEFT)); + assertEquals(8, charges(matrix[4])); + assertEquals(16, matrix[0].getAmount()); + verifyNoInteractions(roll); + verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = Material.class, names = {"PAPER", "ECHO_SHARD"}) + void ordinaryCompassRecipesAreNotHijacked(Material surround) throws Exception { + for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.COMPASS : surround); + result = new ItemStack(surround == Material.PAPER ? Material.MAP : Material.RECOVERY_COMPASS); + var vanillaResult = result.clone(); + preview(); + assertEquals(vanillaResult, result, "Maps and recovery compasses keep their vanilla recipes"); + var craft = take(ClickType.LEFT); + dispatch(craft); + assertFalse(craft.isCancelled()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void nativeExplosionAdapterKeepsCrafterDamageEligibleAndHonorsProtectionCancellation(boolean cancelled) throws Exception { + rechargeMatrix(compass(8)); + var level = mock(net.minecraft.server.level.ServerLevel.class); + var nativeWorld = mock(org.bukkit.craftbukkit.CraftWorld.class); + var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world"); + worldField.setAccessible(true); + worldField.set(nativeWorld, level); + var crafter = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); + var handle = mock(net.minecraft.server.level.ServerPlayer.class); + when(crafter.getHandle()).thenReturn(handle); + when(crafter.getUniqueId()).thenReturn(owner); + when(crafter.getWorld()).thenReturn(nativeWorld); + when(view.getPlayer()).thenReturn(crafter); + UUID worldId = world.getUID(); + var tableBlock = world.getBlockAt(new Location(world, 10, 65, -9)); + when(nativeWorld.getUID()).thenReturn(worldId); + when(nativeWorld.getBlockAt(any(Location.class))).thenReturn(tableBlock); + var table = new Location(nativeWorld, 10, 65, -9); + when(inventory.getLocation()).thenReturn(table); + var damageSources = mock(net.minecraft.world.damagesource.DamageSources.class); + var damageSource = mock(net.minecraft.world.damagesource.DamageSource.class); + when(level.damageSources()).thenReturn(damageSources); + when(damageSources.explosion(handle, handle)).thenReturn(damageSource); + var seen = new java.util.concurrent.atomic.AtomicReference(); + when(level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> { + var nativeExplosion = new net.minecraft.world.level.ServerExplosion(level, inv.getArgument(0), inv.getArgument(1), null, + new net.minecraft.world.phys.Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)), + inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY); + java.util.function.Consumer configure = inv.getArgument(13); + if (configure != null) configure.accept(nativeExplosion); + // The world/protection boundary delivers a real cancellable event. The plugin must not retry it. + var explosionEvent = new org.bukkit.event.entity.EntityExplodeEvent(crafter, table.clone().add(.5, .5, .5), + new ArrayList<>(), 1F, ExplosionResult.DESTROY); + new RegisteredListener(new org.bukkit.event.Listener() {}, (ignored, event) -> ((org.bukkit.event.entity.EntityExplodeEvent) event).setCancelled(cancelled), + org.bukkit.event.EventPriority.HIGH, mock(Plugin.class), false).callEvent(explosionEvent); + nativeExplosion.wasCanceled = explosionEvent.isCancelled(); + seen.set(nativeExplosion); + return nativeExplosion; + }); + doCallRealMethod().when(nativeWorld).createExplosion(any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), any()); + doCallRealMethod().when(nativeWorld).createExplosion(anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), anyBoolean(), any()); + doCallRealMethod().when(nativeWorld).createExplosion(any(org.bukkit.entity.Entity.class), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + when(roll.getAsInt()).thenReturn(99); + var event = take(ClickType.LEFT); + dispatch(event); + var explosion = seen.get(); + assertNotNull(explosion); + assertFalse(explosion.excludeSourceFromDamage, "The crafter must not be made immune by the native overload"); + assertEquals(6F, explosion.radius()); + assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center()); + assertSame(handle, explosion.getDirectSourceEntity()); + assertSame(handle, explosion.getIndirectSourceEntity()); + assertEquals(cancelled, explosion.wasCanceled); + assertNull(matrix[4]); + assertNull(cursor); + assertEquals(15, matrix[0].getAmount()); + dispatch(event); + verify(roll).getAsInt(); + verify(level).explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), eq(false), any(), any(), any(), any(), any(), any()); + } + + @Test void sharingAndKeystoneKeepIndependentOwnersRecipesAndReturnRecords() throws Exception { + var returns = mock(KeystoneReturnStore.class); + var keystoneKey = new NamespacedKey("spigotbase", "pocket_base_keystone"); + var keystones = new PocketBaseKeystoneService(server, pockets, returns, keystoneKey, + java.util.logging.Logger.getAnonymousLogger()); + contents[0] = compass(2); + UUID guest = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(guest); + when(pockets.state(guest)).thenReturn(new PocketBaseState(guest, 1, Optional.empty())); + World bound = mock(World.class); + UUID boundId = UUID.randomUUID(); + when(bound.getUID()).thenReturn(boundId); + when(pockets.ownerForPocketWorld(boundId)).thenReturn(Optional.of(owner)); + var destination = new Location(bound, .5, 65, 5.5); + when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination)); + successfulTeleport(destination); + var sharingUse = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND); + dispatchTo(keystones, sharingUse); + dispatch(sharingUse); + assertEquals(1, charges(contents[0])); + verifyNoInteractions(returns); + verify(pockets, never()).pocketArrival(any()); + var sharing = contents[0].clone(); + + var keystone = PocketBaseKeystoneService.createKeystone(keystoneKey); + contents[0] = keystone; + var visitorReturn = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND); + dispatchTo(keystones, visitorReturn); + dispatch(visitorReturn); + verifyNoInteractions(returns); // A holder-bound Keystone cannot leave somebody else's base. + verify(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)); + + when(player.getWorld()).thenReturn(world); + var origin = new Location(world, 13, 70, 9, 35, 12); + when(player.getLocation()).thenReturn(origin); + when(world.getName()).thenReturn("overworld"); + var ownPocket = new Location(mock(World.class), .5, 65, 5.5); + when(pockets.pocketArrival(guest)).thenReturn(ownPocket); + successfulTeleport(ownPocket); + var ownEntry = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + dispatchTo(keystones, ownEntry); + dispatch(ownEntry); + verify(returns).record(eq(guest), any(KeystoneReturnLocation.class)); + verify(pockets).pocketArrival(guest); + assertEquals(keystone, contents[0]); + assertEquals(1, charges(sharing)); + assertEquals(owner.toString(), sharing.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING)); + + var recipe = PocketBaseKeystoneService.recipe(keystoneKey, keystone); + when(inventory.getRecipe()).thenReturn(recipe); + result = keystone.clone(); + var prepare = new PrepareItemCraftEvent(inventory, view, false); + dispatchTo(keystones, prepare); + dispatch(prepare); + assertEquals(keystone, result); + } + + @Test void sharingGuestRetainsOrdinaryInteractionsAndExistingPortalReturn() throws Exception { + var settings = PluginSettings.from(Map.of()); + var controller = new PocketBaseController(server, mock(BaseStateManager.class), + new BaseBoundsService(settings), pockets, new PluginSettingsProvider(settings), + java.util.logging.Logger.getAnonymousLogger()); + var item = compass(2); + contents[0] = item; + UUID guest = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(guest); + when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest)); + when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner)); + when(pockets.policy()).thenReturn(new PocketBasePolicy(settings)); + var block = mock(org.bukkit.block.Block.class); + when(block.getWorld()).thenReturn(world); + when(block.getX()).thenReturn(10); + when(block.getY()).thenReturn(65); + when(block.getZ()).thenReturn(10); + var place = new org.bukkit.event.block.BlockPlaceEvent(block, mock(org.bukkit.block.BlockState.class), block, + new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND); + dispatchTo(controller, place); + dispatch(place); + assertFalse(place.isCancelled()); + var breaking = new org.bukkit.event.block.BlockBreakEvent(block, player); + dispatchTo(controller, breaking); + dispatch(breaking); + assertFalse(breaking.isCancelled()); + var open = new InventoryOpenEvent(view); + dispatchTo(controller, open); + dispatch(open); + assertFalse(open.isCancelled()); + contents[0] = null; + var interaction = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND); + dispatchTo(controller, interaction); + dispatch(interaction); + assertNotEquals(Event.Result.DENY, interaction.useItemInHand()); + contents[0] = item; + var pocketReturn = new PocketPortalLocation(world.getUID(), "pocket", -2, 65, 0, PocketPortalAxis.X); + when(pockets.returnPortalIsIntact(owner)).thenReturn(true); + when(pockets.returnPortal(owner)).thenReturn(pocketReturn); + var outside = mock(World.class); + var spawn = new Location(outside, 3, 80, 3); + when(server.getWorlds()).thenReturn(List.of(outside)); + when(outside.getSpawnLocation()).thenReturn(spawn); + successfulTeleport(spawn); + var move = new org.bukkit.event.player.PlayerMoveEvent(player, + new Location(world, .5, 66, 5.5), new Location(world, .5, 66, .5)); + dispatchTo(controller, move); + dispatch(move); + verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN); + assertEquals(2, charges(contents[0]), "Existing portal returns do not spend compass charges"); + } + + @Test void failedRechargeConsumesBeforeSynchronousDeathAndNeverRestoresDestroyedCompass() throws Exception { + when(roll.getAsInt()).thenReturn(99); + contents[0] = compass(40); + // compass() uses the crafting fixture; rebuild the transaction after creating carried items. + rechargeMatrix(compass(8)); + var drops = new ArrayList(); + when(world.createExplosion(eq(player), any(Location.class), eq(6F), eq(false), eq(true), eq(false))).thenAnswer(inv -> { + assertNull(matrix[4], "The destroyed compass must be gone before explosion callbacks"); + assertNull(result); + for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount()); + drops.add(contents[0].clone()); + dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class), + drops, 0, net.kyori.adventure.text.Component.text("exploded"), true)); + contents[0] = null; + return true; + }); + var craft = take(ClickType.SHIFT_LEFT); + dispatch(craft); + dispatch(craft); + assertNull(cursor); + assertTrue(Arrays.stream(contents).allMatch(Objects::isNull)); + assertEquals(0, charges(drops.getFirst())); + assertEquals(1, drops.size()); + verify(roll).getAsInt(); + } + + @Test void cancelledDeathLeavesCarriedCompassesAndDropsAlone() throws Exception { + contents[0] = compass(80); + var drops = new ArrayList(List.of(contents[0].clone())); + var event = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class), + drops, 10, net.kyori.adventure.text.Component.text("prevented"), true); + event.setCancelled(true); + dispatch(event); + assertEquals(80, charges(contents[0])); + assertEquals(80, charges(drops.getFirst())); + } + + void creationMatrix() { + for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.END_CRYSTAL : i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD); + } + void dispatch(Event event) throws Exception { dispatchTo(service, event); } + void dispatchTo(org.bukkit.event.Listener listener, Event event) throws Exception { + for (var method : listener.getClass().getMethods()) { + var annotation = method.getAnnotation(EventHandler.class); + if (annotation != null && method.getParameterTypes()[0].isInstance(event)) { + new RegisteredListener(listener, (ignored, e) -> { + try { method.invoke(listener, e); } catch (ReflectiveOperationException ex) { throw new org.bukkit.event.EventException(ex); } + }, annotation.priority(), mock(Plugin.class), annotation.ignoreCancelled()).callEvent(event); + } + } + } + ShapedRecipe candidateRecipe() { + if (matrix[4] != null && matrix[4].getType() == Material.COMPASS && matrix[0] != null) { + if (matrix[0].getType() == Material.PAPER) { + return new ShapedRecipe(NamespacedKey.minecraft("map"), new ItemStack(Material.MAP)); + } + if (matrix[0].getType() == Material.ECHO_SHARD) { + return new ShapedRecipe(NamespacedKey.minecraft("recovery_compass"), new ItemStack(Material.RECOVERY_COMPASS)); + } + } + boolean creation = matrix[4] != null && matrix[4].getType() == Material.END_CRYSTAL; + return new ShapedRecipe(creation ? KEY : field("recharge"), new ItemStack(Material.COMPASS)); + } + void preview() throws Exception { + // Vanilla resolves the recipe/result before PrepareItemCraftEvent is delivered. + result = candidateRecipe().getResult(); + dispatch(new PrepareItemCraftEvent(inventory, view, false)); + } + CraftItemEvent take(ClickType click) throws Exception { + preview(); + return new CraftItemEvent(candidateRecipe(), view, InventoryType.SlotType.RESULT, 0, click, + click.isShiftClick() ? InventoryAction.MOVE_TO_OTHER_INVENTORY : InventoryAction.PICKUP_ALL); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void suppressedRecipePreviewAndResultCannotBeResurrected(boolean recharge) throws Exception { + if (recharge) rechargeMatrix(compass(8)); else creationMatrix(); + result = null; // A preceding protection listener removed the vanilla preview. + dispatch(new PrepareItemCraftEvent(inventory, view, false)); + assertNull(result, "Do not restore a result suppressed by another plugin"); + var event = take(ClickType.LEFT); + result = null; // Also recheck the current result at transaction commit. + dispatch(event); + assertNull(cursor); + assertNotNull(matrix[4]); + verifyNoInteractions(roll); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void staleOwnedRecipeCannotFallThroughToVanillaAfterIngredientsChange(boolean recharge) throws Exception { + if (recharge) rechargeMatrix(compass(8)); else creationMatrix(); + var event = take(ClickType.LEFT); + assertNotNull(result); + matrix[4] = null; // Another listener changed the matrix after native recipe resolution. + dispatch(event); + assertTrue(event.isCancelled(), "An owned result must never fall through to vanilla payment"); + assertNull(result, "Remove a stale owned preview rather than returning an unpaid result"); + assertNull(cursor); + verifyNoInteractions(roll); + } + + @Test void foreignRecipeWithSameIngredientsIsNotReplacedOrManuallyCrafted() throws Exception { + creationMatrix(); + var foreign = new ShapedRecipe(new NamespacedKey("other", "recipe"), new ItemStack(Material.DIAMOND)); + when(inventory.getRecipe()).thenReturn(foreign); + result = foreign.getResult(); + dispatch(new PrepareItemCraftEvent(inventory, view, false)); + assertEquals(Material.DIAMOND, result.getType()); + var event = new CraftItemEvent(foreign, view, InventoryType.SlotType.RESULT, 0, + ClickType.LEFT, InventoryAction.PICKUP_ALL); + dispatch(event); + assertFalse(event.isCancelled()); + assertNull(cursor); + assertNotNull(matrix[4]); + } + + @Test void takingCreationPaysOnceBindsCrafterAndRejectsLockedAndCancelledCrafts() throws Exception { + creationMatrix(); + preview(); + var cancelled = take(ClickType.LEFT); + cancelled.setCancelled(true); + dispatch(cancelled); + assertNull(cursor); + assertEquals(1, matrix[0].getAmount()); + var event = take(ClickType.LEFT); + dispatch(event); + assertNotNull(cursor, "Taking the result must commit one paid compass"); + assertTrue(event.isCancelled(), "Vanilla result-taking must not also produce an item"); + assertTrue(Arrays.stream(matrix).allMatch(Objects::isNull)); + assertEquals(owner.toString(), cursor.getItemMeta().getPersistentDataContainer().get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING)); + var paid = cursor.clone(); + dispatch(take(ClickType.SHIFT_LEFT)); + assertTrue(Arrays.stream(contents).allMatch(Objects::isNull)); + assertEquals(paid, cursor); + cursor = null; + creationMatrix(); + when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner)); + dispatch(take(ClickType.LEFT)); + assertNull(cursor); + assertEquals(1, matrix[4].getAmount()); + } + + @Test void unlockedOwnerGetsEmptyMetadataBoundPreviewFromExactRecipeOnly() throws Exception { + creationMatrix(); + preview(); + assertNotNull(result, "The approved recipe must preview a sharing compass"); + assertEquals(Material.COMPASS, result.getType()); + var data = result.getItemMeta().getPersistentDataContainer(); + assertEquals((byte) 1, data.get(KEY, PersistentDataType.BYTE)); + assertEquals(owner.toString(), data.get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING)); + assertEquals(0L, data.get(new NamespacedKey("spigotbase", "sharing_compass_charges"), PersistentDataType.LONG)); + verifyNoInteractions(roll); + assertEquals(1, matrix[4].getAmount()); + matrix[0] = new ItemStack(Material.DIAMOND); + preview(); + assertNull(result); + creationMatrix(); + when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner)); + preview(); + assertNull(result); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java b/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java index b8974a5..f10f64b 100644 --- a/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java @@ -38,11 +38,15 @@ class BaseIdentificationWiringTest { doCallRealMethod().when(plugin).onDisable(); try (var validator = mockStatic(PluginSettingsValidator.class); var keystones = mockConstruction(PocketBaseKeystoneService.class); + var sharing = mockConstruction(PocketBaseSharingCompass.class); var packets = mockConstruction(NativeBaseLabelPackets.class); var labels = mockConstruction(BukkitBaseLabels.class)) { validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0)); plugin.onEnable(); assertEquals(1, keystones.constructed().size()); + assertEquals(1, sharing.constructed().size(), "Enable must install guest compasses alongside Keystones"); + verify(sharing.constructed().getFirst()).registerRecipes(); + verify(manager).registerEvents(sharing.constructed().getFirst(), plugin); verify(manager, never()).disablePlugin(plugin); var tasks = ArgumentCaptor.forClass(Runnable.class); verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong()); diff --git a/src/test/java/games/dmg/spigotbase/SharingCompassDestinationTest.java b/src/test/java/games/dmg/spigotbase/SharingCompassDestinationTest.java new file mode 100644 index 0000000..038f97f --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/SharingCompassDestinationTest.java @@ -0,0 +1,35 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SharingCompassDestinationTest { + @TempDir Path directory; + + @Test void destinationUsesPersistedUnlockAndLoadedWorldWithoutCreatingMissingWorlds() throws Exception { + UUID owner = UUID.randomUUID(); + var repository = new YamlPocketBaseRepository(directory.resolve("pockets.yml")); + var states = new PocketBaseStateManager(repository); + states.updateAndSave(owner, state -> state.withLevel(1)); + var server = mock(Server.class); + var settings = new PluginSettingsProvider(PluginSettings.from(Map.of())); + var worlds = new PocketBaseWorldService(server, settings); + var manager = new PocketBaseManager(new PocketBaseStateManager(repository), worlds, settings); + assertTrue(manager.availablePocketArrival(owner).isEmpty()); + verify(server, never()).createWorld(any()); + var world = mock(World.class); + when(server.getWorld("spigot_base_pocket_" + owner.toString().replace("-", ""))).thenReturn(world); + assertEquals(new Location(world, .5, 65, 5.5, 180F, 0F), manager.availablePocketArrival(owner).orElseThrow()); + assertTrue(manager.availablePocketArrival(UUID.randomUUID()).isEmpty()); + verify(server, never()).createWorld(any()); + } +}