diff --git a/design/user-stories/us-023-craft-pocket-base-keystone.md b/design/user-stories/us-023-craft-pocket-base-keystone.md index eab24db..bf6e9af 100644 --- a/design/user-stories/us-023-craft-pocket-base-keystone.md +++ b/design/user-stories/us-023-craft-pocket-base-keystone.md @@ -24,6 +24,15 @@ As a **Pocket Base owner**, I want to craft a reusable keystone that instantly t - [x] Keystone travel clears accumulated fall distance and does not change existing portal travel behavior. - [x] Automated tests cover recipe shape, crafting and use eligibility, metadata authentication, entry, return, persistence, and unavailable or unsafe destinations. +- [x] Keystone entry and return work when right-clicking air or blocks, regardless of facing direction. +- [x] Vanilla no-action events are handled without bypassing explicit item-use restrictions. +- [x] Regression tests cover air and block interactions for entry and return, and denied item use. + +## Verification + +- `./gradlew clean check jar` passes, including event-dispatch regression coverage for air/block entry and return and explicit item-use denial. +- Live-server verification remains pending deployment. + ## Related - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java index c4c49c8..cb67a6e 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java @@ -17,6 +17,7 @@ import org.bukkit.block.Block; 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; @@ -72,13 +73,18 @@ final class PocketBaseKeystoneService implements Listener { } } - @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGH) public void onUse(PlayerInteractEvent event) { Action action = event.getAction(); if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) || !isKeystone(event.getItem(), markerKey)) { return; } + // Vanilla no-action air clicks can deny block use without denying item use. + // Accept those events, but respect explicit item-use denial by other plugins. + if (event.useItemInHand() == Event.Result.DENY) { + return; + } event.setCancelled(true); Player player = event.getPlayer(); UUID playerId = player.getUniqueId(); diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java index 4b33210..801f97d 100644 --- a/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java +++ b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java @@ -20,7 +20,11 @@ import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; import org.bukkit.event.block.Action; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredListener; import org.bukkit.event.inventory.PrepareItemCraftEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerTeleportEvent; @@ -32,6 +36,8 @@ import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; final class PocketBaseKeystoneServiceTest { @Test @@ -122,8 +128,9 @@ final class PocketBaseKeystoneServiceTest { ); } - @Test - void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination() throws Exception { + @ParameterizedTest + @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) + void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception { NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); UUID playerId = UUID.randomUUID(); UUID worldId = UUID.randomUUID(); @@ -155,7 +162,10 @@ final class PocketBaseKeystoneServiceTest { when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN)) .thenReturn(true); - service.onUse(event); + when(event.getAction()).thenReturn(action); + when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR); + when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT); + dispatchUse(service, event); verify(returns).record(playerId, new KeystoneReturnLocation( worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F @@ -165,8 +175,9 @@ final class PocketBaseKeystoneServiceTest { verify(event).setCancelled(true); } - @Test - void playerReturnsFromOwnPocketToRecordedSafeDestination() { + @ParameterizedTest + @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) + void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception { NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); UUID playerId = UUID.randomUUID(); UUID pocketWorldId = UUID.randomUUID(); @@ -217,7 +228,10 @@ final class PocketBaseKeystoneServiceTest { PlayerTeleportEvent.TeleportCause.PLUGIN )).thenReturn(true); - service.onUse(event); + when(event.getAction()).thenReturn(action); + when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR); + when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT); + dispatchUse(service, event); verify(player).teleport( new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F), @@ -324,6 +338,51 @@ final class PocketBaseKeystoneServiceTest { assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key)); } + @ParameterizedTest + @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) + void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + KeystoneReturnStore returns = mock(KeystoneReturnStore.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger() + ); + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + ItemStack keystone = markedKeystone(key); + when(event.getAction()).thenReturn(action); + when(event.getItem()).thenReturn(keystone); + when(event.getPlayer()).thenReturn(player); + when(event.isCancelled()).thenReturn(true); + when(event.useItemInHand()).thenReturn(Event.Result.DENY); + when(player.getUniqueId()).thenReturn(playerId); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 0, Optional.empty()) + ); + + dispatchUse(service, event); + + verify(pocketBases, never()).state(any(UUID.class)); + verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class)); + verify(player, never()).teleport( + any(Location.class), any(PlayerTeleportEvent.TeleportCause.class) + ); + verify(event, never()).setCancelled(true); + } + + private static void dispatchUse( + PocketBaseKeystoneService service, PlayerInteractEvent event + ) throws Exception { + EventHandler handler = PocketBaseKeystoneService.class + .getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class); + RegisteredListener listener = new RegisteredListener( + service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched), + handler.priority(), mock(Plugin.class), handler.ignoreCancelled() + ); + listener.callEvent(event); + } + private static ItemStack markedKeystone(NamespacedKey key) { ItemStack item = mock(ItemStack.class); ItemMeta metadata = mock(ItemMeta.class);