fix(pocket-base): handle keystone right-clicks in air
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m31s

This commit is contained in:
dmg
2026-09-06 22:14:18 -04:00
parent 28dd5422d5
commit 1ad930c7ad
3 changed files with 81 additions and 7 deletions
@@ -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] 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] 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 ## Related
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
@@ -17,6 +17,7 @@ import org.bukkit.block.Block;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; 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) { public void onUse(PlayerInteractEvent event) {
Action action = event.getAction(); Action action = event.getAction();
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|| !isKeystone(event.getItem(), markerKey)) { || !isKeystone(event.getItem(), markerKey)) {
return; 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); event.setCancelled(true);
Player player = event.getPlayer(); Player player = event.getPlayer();
UUID playerId = player.getUniqueId(); UUID playerId = player.getUniqueId();
@@ -20,7 +20,11 @@ import org.bukkit.Server;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action; 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.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent; 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.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
final class PocketBaseKeystoneServiceTest { final class PocketBaseKeystoneServiceTest {
@Test @Test
@@ -122,8 +128,9 @@ final class PocketBaseKeystoneServiceTest {
); );
} }
@Test @ParameterizedTest
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination() throws Exception { @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID(); UUID worldId = UUID.randomUUID();
@@ -155,7 +162,10 @@ final class PocketBaseKeystoneServiceTest {
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN)) when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
.thenReturn(true); .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( verify(returns).record(playerId, new KeystoneReturnLocation(
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
@@ -165,8 +175,9 @@ final class PocketBaseKeystoneServiceTest {
verify(event).setCancelled(true); verify(event).setCancelled(true);
} }
@Test @ParameterizedTest
void playerReturnsFromOwnPocketToRecordedSafeDestination() { @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID(); UUID pocketWorldId = UUID.randomUUID();
@@ -217,7 +228,10 @@ final class PocketBaseKeystoneServiceTest {
PlayerTeleportEvent.TeleportCause.PLUGIN PlayerTeleportEvent.TeleportCause.PLUGIN
)).thenReturn(true); )).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( verify(player).teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F), 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)); 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) { private static ItemStack markedKeystone(NamespacedKey key) {
ItemStack item = mock(ItemStack.class); ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class); ItemMeta metadata = mock(ItemMeta.class);