From 61e7a0137496156f122d533f47c23478b19db38c Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 29 Aug 2026 14:43:12 -0400 Subject: [PATCH] feat(pocket-base): add reusable keystone travel --- README.md | 2 + design/log.md | 8 + design/user-stories/index.md | 1 + .../us-015-unlock-a-pocket-base.md | 1 + ...s-016-build-and-use-pocket-base-portals.md | 1 + .../us-023-craft-pocket-base-keystone.md | 29 ++ .../spigotbase/KeystoneReturnLocation.java | 26 ++ .../dmg/spigotbase/KeystoneReturnStore.java | 35 ++ .../spigotbase/PocketBaseKeystoneService.java | 250 ++++++++++++++ .../dmg/spigotbase/SpigotBasePlugin.java | 14 + .../YamlKeystoneReturnRepository.java | 94 +++++ .../PocketBaseKeystoneServiceTest.java | 321 ++++++++++++++++++ .../YamlKeystoneReturnRepositoryTest.java | 29 ++ 13 files changed, 811 insertions(+) create mode 100644 design/user-stories/us-023-craft-pocket-base-keystone.md create mode 100644 src/main/java/games/dmg/spigotbase/KeystoneReturnLocation.java create mode 100644 src/main/java/games/dmg/spigotbase/KeystoneReturnStore.java create mode 100644 src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java create mode 100644 src/main/java/games/dmg/spigotbase/YamlKeystoneReturnRepository.java create mode 100644 src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java create mode 100644 src/test/java/games/dmg/spigotbase/YamlKeystoneReturnRepositoryTest.java diff --git a/README.md b/README.md index 8b0d13d..133492f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs `. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight `. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. The owner's automatic-flight preference does not affect portal-granted flight. +Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base. + Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type `. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change. ## Administration diff --git a/design/log.md b/design/log.md index ce04e7b..8ee762b 100644 --- a/design/log.md +++ b/design/log.md @@ -164,3 +164,11 @@ description: Chronological record of material decisions affecting the Spigot Bas - Applied the expanding Pocket Base boundary plus a 16-block warning buffer to both automatic and portal-granted flight. - Restored Back buttons from nested native dialog screens to the main Base Settings dashboard. - Verified 149 tests and the Java 25 plugin artifact with `./gradlew clean check jar`. + +## 2026-08-29 — Pocket Base Keystone travel + +- Added a reusable Recovery Compass keystone recipe with an Ender Pearl center, cardinal Netherite Ingots, and Obsidian corners. +- Limited crafting and use to Pocket Base owners while allowing transferred keystones to work for each eligible holder's own Pocket Base. +- Added instant entry and safe return travel with exact, durable return destinations and fall-distance clearing. +- Rejected unavailable and unsafe returns without discarding the saved destination. +- Verified 157 tests and the Java 25 plugin artifact with `./gradlew clean check jar`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index fa1ce30..edf26fb 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -28,3 +28,4 @@ description: Catalog of user stories for the Spigot Base plugin. 20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md) 21. [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md) 22. [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md) +23. [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md) diff --git a/design/user-stories/us-015-unlock-a-pocket-base.md b/design/user-stories/us-015-unlock-a-pocket-base.md index 39008a4..f3ad2f4 100644 --- a/design/user-stories/us-015-unlock-a-pocket-base.md +++ b/design/user-stories/us-015-unlock-a-pocket-base.md @@ -28,3 +28,4 @@ As a **player with an established Base IV**, I want to unlock a persistent Pocke - [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md) - [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md) - [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) +- [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md) diff --git a/design/user-stories/us-016-build-and-use-pocket-base-portals.md b/design/user-stories/us-016-build-and-use-pocket-base-portals.md index e30c960..7949500 100644 --- a/design/user-stories/us-016-build-and-use-pocket-base-portals.md +++ b/design/user-stories/us-016-build-and-use-pocket-base-portals.md @@ -46,3 +46,4 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) - [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) - [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md) +- [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md) diff --git a/design/user-stories/us-023-craft-pocket-base-keystone.md b/design/user-stories/us-023-craft-pocket-base-keystone.md new file mode 100644 index 0000000..38023c4 --- /dev/null +++ b/design/user-stories/us-023-craft-pocket-base-keystone.md @@ -0,0 +1,29 @@ +--- +type: User Story +title: "US-023: Craft and use a Pocket Base Keystone" +description: Let Pocket Base owners craft a reusable item for instant round-trip travel between their current location and their private dimension. +status: done +--- + +# US-023: Craft and use a Pocket Base Keystone + +As a **Pocket Base owner**, I want to craft a reusable keystone that instantly takes me into my Pocket Base and back so that I can access my private dimension without finding a portal. + +## Acceptance criteria + +- [x] The Pocket Base Keystone is a distinct Recovery Compass identified by persistent item metadata rather than its display name alone. +- [x] Its shaped recipe places an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. +- [x] Only a player who has unlocked Pocket Base I can complete the recipe or use a keystone. +- [x] Right-clicking a keystone outside a Pocket Base instantly teleports the player to their own Pocket Base arrival point. +- [x] Entering records the player's exact prior world, position, yaw, and pitch as the return destination. +- [x] Right-clicking a keystone inside the player's own Pocket Base returns them safely to the recorded destination. +- [x] Return destinations persist across logout and server restart. +- [x] Missing worlds and unsafe destinations fail safely without replacing or discarding the recorded return destination. +- [x] A keystone is reusable, has no warm-up or cooldown, and always accesses the user's own unlocked Pocket Base even when transferred between players. +- [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. + +## Related + +- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) +- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md) diff --git a/src/main/java/games/dmg/spigotbase/KeystoneReturnLocation.java b/src/main/java/games/dmg/spigotbase/KeystoneReturnLocation.java new file mode 100644 index 0000000..ce29d33 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/KeystoneReturnLocation.java @@ -0,0 +1,26 @@ +package games.dmg.spigotbase; + +import java.util.Objects; +import java.util.UUID; + +public record KeystoneReturnLocation( + UUID worldId, + String worldName, + double x, + double y, + double z, + float yaw, + float pitch +) { + public KeystoneReturnLocation { + Objects.requireNonNull(worldId, "worldId"); + Objects.requireNonNull(worldName, "worldName"); + if (worldName.isBlank()) { + throw new IllegalArgumentException("worldName must not be blank"); + } + if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z) + || !Float.isFinite(yaw) || !Float.isFinite(pitch)) { + throw new IllegalArgumentException("return coordinates must be finite"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/KeystoneReturnStore.java b/src/main/java/games/dmg/spigotbase/KeystoneReturnStore.java new file mode 100644 index 0000000..1e4c3b9 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/KeystoneReturnStore.java @@ -0,0 +1,35 @@ +package games.dmg.spigotbase; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class KeystoneReturnStore { + private final YamlKeystoneReturnRepository repository; + private final Map destinations; + + KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException { + this.repository = repository; + this.destinations = new HashMap<>(repository.load()); + } + + Optional destination(UUID playerId) { + return Optional.ofNullable(destinations.get(playerId)); + } + + void record(UUID playerId, KeystoneReturnLocation destination) throws IOException { + KeystoneReturnLocation previous = destinations.put(playerId, destination); + try { + repository.save(destinations); + } catch (IOException exception) { + if (previous == null) { + destinations.remove(playerId); + } else { + destinations.put(playerId, previous); + } + throw exception; + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java new file mode 100644 index 0000000..32768e7 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java @@ -0,0 +1,250 @@ +package games.dmg.spigotbase; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.entity.Player; +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.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.CompassMeta; +import org.bukkit.persistence.PersistentDataType; + +final class PocketBaseKeystoneService implements Listener { + private static final Set RETURN_HAZARDS = Set.of( + Material.LAVA, + Material.FIRE, + Material.SOUL_FIRE, + Material.MAGMA_BLOCK, + Material.CACTUS, + Material.CAMPFIRE, + Material.SOUL_CAMPFIRE, + Material.POWDER_SNOW + ); + private final Server server; + private final PocketBaseManager pocketBases; + private final KeystoneReturnStore returns; + private final NamespacedKey markerKey; + private final Logger logger; + + PocketBaseKeystoneService( + Server server, + PocketBaseManager pocketBases, + KeystoneReturnStore returns, + NamespacedKey markerKey, + Logger logger + ) { + this.server = server; + this.pocketBases = pocketBases; + this.returns = returns; + this.markerKey = markerKey; + this.logger = logger; + } + + @EventHandler(priority = EventPriority.HIGH) + public void onPrepareCraft(PrepareItemCraftEvent event) { + if (!(event.getRecipe() instanceof ShapedRecipe shaped) + || !shaped.getKey().equals(markerKey) + || !(event.getView().getPlayer() instanceof Player player)) { + return; + } + if (pocketBases.state(player.getUniqueId()).level() < 1) { + event.getInventory().setResult(null); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + 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; + } + event.setCancelled(true); + Player player = event.getPlayer(); + UUID playerId = player.getUniqueId(); + if (pocketBases.state(playerId).level() < 1) { + player.sendMessage(ChatColor.RED + + "Unlock Pocket Base I before using a Pocket Base Keystone."); + return; + } + Optional currentPocket = pocketBases.ownerForPocketWorld( + player.getWorld().getUID() + ); + if (currentPocket.isPresent()) { + if (!currentPocket.orElseThrow().equals(playerId)) { + player.sendMessage(ChatColor.RED + + "You can only use a keystone to leave your own Pocket Base."); + return; + } + returnToSavedDestination(player); + return; + } + Location origin = player.getLocation(); + World originWorld = origin.getWorld(); + if (originWorld == null) { + player.sendMessage(ChatColor.RED + "Your return location could not be recorded."); + return; + } + KeystoneReturnLocation destination = new KeystoneReturnLocation( + originWorld.getUID(), + originWorld.getName(), + origin.getX(), + origin.getY(), + origin.getZ(), + origin.getYaw(), + origin.getPitch() + ); + try { + returns.record(playerId, destination); + } catch (IOException exception) { + logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception); + player.sendMessage(ChatColor.RED + + "Your return location could not be saved, so you were not teleported."); + return; + } + teleport(player, pocketBases.pocketArrival(playerId)); + } + + private void returnToSavedDestination(Player player) { + Optional saved = returns.destination(player.getUniqueId()); + if (saved.isEmpty()) { + player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded."); + return; + } + KeystoneReturnLocation destination = saved.orElseThrow(); + World world = server.getWorld(destination.worldId()); + if (world == null) { + player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable."); + return; + } + Location location = new Location( + world, + destination.x(), + destination.y(), + destination.z(), + destination.yaw(), + destination.pitch() + ); + if (!isSafe(location)) { + player.sendMessage(ChatColor.RED + "Your recorded return location is not safe."); + return; + } + teleport(player, location); + } + + private static void teleport(Player player, Location destination) { + float previousFallDistance = player.getFallDistance(); + player.setFallDistance(0.0F); + if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) { + player.setFallDistance(previousFallDistance); + player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed."); + } + } + + private static boolean isSafe(Location location) { + World world = location.getWorld(); + int x = location.getBlockX(); + int y = location.getBlockY(); + int z = location.getBlockZ(); + if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) { + return false; + } + Block ground = world.getBlockAt(x, y - 1, z); + Block feet = world.getBlockAt(x, y, z); + Block head = world.getBlockAt(x, y + 1, z); + return feet.isPassable() && head.isPassable() + && !feet.isLiquid() && !head.isLiquid() + && !ground.isPassable() && !ground.isLiquid() + && !RETURN_HAZARDS.contains(ground.getType()) + && !RETURN_HAZARDS.contains(feet.getType()) + && !RETURN_HAZARDS.contains(head.getType()); + } + + static boolean isKeystone(ItemStack item, NamespacedKey markerKey) { + if (item == null || item.getType() != Material.RECOVERY_COMPASS) { + return false; + } + ItemMeta metadata = item.getItemMeta(); + if (metadata == null) { + return false; + } + Byte marker = metadata.getPersistentDataContainer().get( + markerKey, PersistentDataType.BYTE + ); + return marker != null && marker == (byte) 1; + } + + void registerRecipe() { + server.addRecipe(recipe(markerKey, createKeystone(markerKey))); + } + + static ItemStack createKeystone(NamespacedKey markerKey) { + ItemStack item = new ItemStack(Material.RECOVERY_COMPASS); + ItemMeta metadata = item.getItemMeta(); + if (!(metadata instanceof CompassMeta)) { + throw new IllegalStateException("Recovery Compass metadata is unavailable"); + } + metadata.displayName(Component.text( + "Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE + )); + metadata.lore(List.of( + Component.text( + "Right-click to enter your Pocket Base.", NamedTextColor.GRAY + ), + Component.text("Use it again inside to return.", NamedTextColor.GRAY) + )); + metadata.getPersistentDataContainer().set( + markerKey, PersistentDataType.BYTE, (byte) 1 + ); + item.setItemMeta(metadata); + return item; + } + + static RecipeSpec recipeSpecification() { + return new RecipeSpec( + "ONO", "NEN", "ONO", Material.OBSIDIAN, + Material.NETHERITE_INGOT, Material.ENDER_PEARL + ); + } + + static ShapedRecipe recipe(NamespacedKey key, ItemStack result) { + RecipeSpec specification = recipeSpecification(); + ShapedRecipe recipe = new ShapedRecipe(key, result); + recipe.shape(specification.top(), specification.middle(), specification.bottom()); + recipe.setIngredient('O', specification.obsidian()); + recipe.setIngredient('N', specification.netherite()); + recipe.setIngredient('E', specification.enderPearl()); + return recipe; + } + + record RecipeSpec( + String top, + String middle, + String bottom, + Material obsidian, + Material netherite, + Material enderPearl + ) { + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 4a15001..4460118 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -5,6 +5,7 @@ import java.time.Clock; import java.util.Map; import java.util.Objects; import java.util.logging.Level; +import org.bukkit.NamespacedKey; import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; @@ -16,6 +17,7 @@ public final class SpigotBasePlugin extends JavaPlugin { private BaseTeleportManager teleportManager; private PocketBaseManager pocketBaseManager; private PocketBaseController pocketBaseController; + private PocketBaseKeystoneService keystoneService; @Override public void onEnable() { @@ -40,6 +42,17 @@ public final class SpigotBasePlugin extends JavaPlugin { settingsProvider ); pocketBaseManager.loadWorlds(); + NamespacedKey keystoneKey = new NamespacedKey(this, "pocket_base_keystone"); + keystoneService = new PocketBaseKeystoneService( + getServer(), + pocketBaseManager, + new KeystoneReturnStore(new YamlKeystoneReturnRepository( + getDataFolder().toPath().resolve("keystone-returns.yml") + )), + keystoneKey, + getLogger() + ); + keystoneService.registerRecipe(); } catch (RuntimeException | IOException exception) { getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception); getServer().getPluginManager().disablePlugin(this); @@ -89,6 +102,7 @@ public final class SpigotBasePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(flightController, this); getServer().getPluginManager().registerEvents(teleportManager, this); getServer().getPluginManager().registerEvents(pocketBaseController, this); + getServer().getPluginManager().registerEvents(keystoneService, this); command("setbase").setExecutor(new SetBaseCommand( stateManager, baseService, Clock.systemUTC(), pocketBaseController diff --git a/src/main/java/games/dmg/spigotbase/YamlKeystoneReturnRepository.java b/src/main/java/games/dmg/spigotbase/YamlKeystoneReturnRepository.java new file mode 100644 index 0000000..21b962a --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/YamlKeystoneReturnRepository.java @@ -0,0 +1,94 @@ +package games.dmg.spigotbase; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class YamlKeystoneReturnRepository { + private final Path stateFile; + + public YamlKeystoneReturnRepository(Path stateFile) { + this.stateFile = stateFile; + } + + public Map load() throws IOException { + if (!Files.exists(stateFile)) { + return Map.of(); + } + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.load(stateFile.toFile()); + } catch (InvalidConfigurationException exception) { + throw new IOException("Pocket Base Keystone returns are not valid YAML", exception); + } + Map destinations = new HashMap<>(); + ConfigurationSection players = yaml.getConfigurationSection("players"); + if (players == null) { + return destinations; + } + for (String key : players.getKeys(false)) { + try { + String path = "players." + key; + String worldId = yaml.getString(path + ".world-id"); + String worldName = yaml.getString(path + ".world-name"); + if (worldId == null || worldName == null) { + continue; + } + destinations.put(UUID.fromString(key), new KeystoneReturnLocation( + UUID.fromString(worldId), + worldName, + yaml.getDouble(path + ".x"), + yaml.getDouble(path + ".y"), + yaml.getDouble(path + ".z"), + (float) yaml.getDouble(path + ".yaw"), + (float) yaml.getDouble(path + ".pitch") + )); + } catch (IllegalArgumentException ignored) { + // Invalid records do not provide a return destination. + } + } + return destinations; + } + + public void save(Map destinations) throws IOException { + Path parent = stateFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + YamlConfiguration yaml = new YamlConfiguration(); + for (Map.Entry entry : destinations.entrySet()) { + String path = "players." + entry.getKey(); + KeystoneReturnLocation destination = entry.getValue(); + yaml.set(path + ".world-id", destination.worldId().toString()); + yaml.set(path + ".world-name", destination.worldName()); + yaml.set(path + ".x", destination.x()); + yaml.set(path + ".y", destination.y()); + yaml.set(path + ".z", destination.z()); + yaml.set(path + ".yaw", destination.yaw()); + yaml.set(path + ".pitch", destination.pitch()); + } + Path temporary = Files.createTempFile(parent, "spigot-base-keystone-", ".yml"); + try { + yaml.save(temporary.toFile()); + try { + Files.move( + temporary, + stateFile, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ); + } catch (IOException atomicMoveFailure) { + Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java new file mode 100644 index 0000000..9601cb8 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java @@ -0,0 +1,321 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.CraftingInventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; + +final class PocketBaseKeystoneServiceTest { + @Test + void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() { + PocketBaseKeystoneService.RecipeSpec recipe = + PocketBaseKeystoneService.recipeSpecification(); + + assertEquals("ONO", recipe.top()); + assertEquals("NEN", recipe.middle()); + assertEquals("ONO", recipe.bottom()); + assertEquals(Material.OBSIDIAN, recipe.obsidian()); + assertEquals(Material.NETHERITE_INGOT, recipe.netherite()); + assertEquals(Material.ENDER_PEARL, recipe.enderPearl()); + } + + @Test + void lockedPlayersCannotCompleteTheKeystoneRecipe() { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class); + InventoryView view = mock(InventoryView.class); + CraftingInventory inventory = mock(CraftingInventory.class); + ShapedRecipe recipe = mock(ShapedRecipe.class); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + mock(org.bukkit.Server.class), pocketBases, mock(KeystoneReturnStore.class), + key, Logger.getAnonymousLogger() + ); + + when(player.getUniqueId()).thenReturn(playerId); + when(event.getView()).thenReturn(view); + when(view.getPlayer()).thenReturn(player); + when(event.getInventory()).thenReturn(inventory); + when(event.getRecipe()).thenReturn(recipe); + when(recipe.getKey()).thenReturn(key); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 0, Optional.empty()) + ); + + service.onPrepareCraft(event); + + verify(inventory).setResult(null); + } + + @Test + void lockedPlayerCannotUseATransferredKeystone() throws Exception { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + ItemStack keystone = markedKeystone(key); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + KeystoneReturnStore returns = mock(KeystoneReturnStore.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger() + ); + + when(player.getUniqueId()).thenReturn(playerId); + when(event.getPlayer()).thenReturn(player); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(event.getItem()).thenReturn(keystone); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 0, Optional.empty()) + ); + + service.onUse(event); + + verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class)); + verify(player, never()).teleport( + any(Location.class), any(PlayerTeleportEvent.TeleportCause.class) + ); + } + + @Test + void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination() throws Exception { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID playerId = UUID.randomUUID(); + UUID worldId = UUID.randomUUID(); + Player player = mock(Player.class); + World world = mock(World.class); + Location origin = new Location(world, 12.25, 70.5, -4.75, 123.0F, -15.5F); + Location arrival = new Location(mock(World.class), 0.5, 65.0, 0.5); + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + ItemStack keystone = markedKeystone(key); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + KeystoneReturnStore returns = mock(KeystoneReturnStore.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger() + ); + + when(player.getUniqueId()).thenReturn(playerId); + when(player.getLocation()).thenReturn(origin); + when(player.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(worldId); + when(world.getName()).thenReturn("world"); + when(event.getPlayer()).thenReturn(player); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(event.getItem()).thenReturn(keystone); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 1, Optional.empty()) + ); + when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.empty()); + when(pocketBases.pocketArrival(playerId)).thenReturn(arrival); + when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN)) + .thenReturn(true); + + service.onUse(event); + + verify(returns).record(playerId, new KeystoneReturnLocation( + worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F + )); + verify(player).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN); + verify(player).setFallDistance(0.0F); + verify(event).setCancelled(true); + } + + @Test + void playerReturnsFromOwnPocketToRecordedSafeDestination() { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID playerId = UUID.randomUUID(); + UUID pocketWorldId = UUID.randomUUID(); + UUID returnWorldId = UUID.randomUUID(); + Player player = mock(Player.class); + World pocketWorld = mock(World.class); + World returnWorld = mock(World.class); + Block ground = mock(Block.class); + Block feet = mock(Block.class); + Block head = mock(Block.class); + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + ItemStack keystone = markedKeystone(key); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + KeystoneReturnStore returns = mock(KeystoneReturnStore.class); + Server server = mock(Server.class); + KeystoneReturnLocation saved = new KeystoneReturnLocation( + returnWorldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F + ); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + server, pocketBases, returns, key, Logger.getAnonymousLogger() + ); + + when(player.getUniqueId()).thenReturn(playerId); + when(player.getWorld()).thenReturn(pocketWorld); + when(pocketWorld.getUID()).thenReturn(pocketWorldId); + when(event.getPlayer()).thenReturn(player); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(event.getItem()).thenReturn(keystone); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 1, Optional.empty()) + ); + when(pocketBases.ownerForPocketWorld(pocketWorldId)) + .thenReturn(Optional.of(playerId)); + when(returns.destination(playerId)).thenReturn(Optional.of(saved)); + when(server.getWorld(returnWorldId)).thenReturn(returnWorld); + when(returnWorld.getMinHeight()).thenReturn(-64); + when(returnWorld.getMaxHeight()).thenReturn(320); + when(returnWorld.getBlockAt(12, 69, -5)).thenReturn(ground); + when(returnWorld.getBlockAt(12, 70, -5)).thenReturn(feet); + when(returnWorld.getBlockAt(12, 71, -5)).thenReturn(head); + when(ground.getType()).thenReturn(Material.STONE); + when(feet.getType()).thenReturn(Material.AIR); + when(head.getType()).thenReturn(Material.AIR); + when(feet.isPassable()).thenReturn(true); + when(head.isPassable()).thenReturn(true); + when(player.teleport( + new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F), + PlayerTeleportEvent.TeleportCause.PLUGIN + )).thenReturn(true); + + service.onUse(event); + + verify(player).teleport( + new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F), + PlayerTeleportEvent.TeleportCause.PLUGIN + ); + verify(player).setFallDistance(0.0F); + } + + @Test + void unavailableAndUnsafeReturnsDoNotTeleportThePlayer() { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID missingPocketWorldId = UUID.randomUUID(); + UUID unsafePocketWorldId = UUID.randomUUID(); + UUID missingPlayerId = UUID.randomUUID(); + UUID unsafePlayerId = UUID.randomUUID(); + UUID missingWorldId = UUID.randomUUID(); + UUID unsafeWorldId = UUID.randomUUID(); + World missingPocketWorld = mock(World.class); + World unsafePocketWorld = mock(World.class); + World unsafeWorld = mock(World.class); + Player missingPlayer = mock(Player.class); + Player unsafePlayer = mock(Player.class); + PlayerInteractEvent missingEvent = mock(PlayerInteractEvent.class); + PlayerInteractEvent unsafeEvent = mock(PlayerInteractEvent.class); + ItemStack missingKeystone = markedKeystone(key); + ItemStack unsafeKeystone = markedKeystone(key); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + KeystoneReturnStore returns = mock(KeystoneReturnStore.class); + Server server = mock(Server.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + server, pocketBases, returns, key, Logger.getAnonymousLogger() + ); + + when(missingPocketWorld.getUID()).thenReturn(missingPocketWorldId); + when(unsafePocketWorld.getUID()).thenReturn(unsafePocketWorldId); + when(missingPlayer.getUniqueId()).thenReturn(missingPlayerId); + when(missingPlayer.getWorld()).thenReturn(missingPocketWorld); + when(unsafePlayer.getUniqueId()).thenReturn(unsafePlayerId); + when(unsafePlayer.getWorld()).thenReturn(unsafePocketWorld); + when(missingEvent.getPlayer()).thenReturn(missingPlayer); + when(missingEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(missingEvent.getItem()).thenReturn(missingKeystone); + when(unsafeEvent.getPlayer()).thenReturn(unsafePlayer); + when(unsafeEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(unsafeEvent.getItem()).thenReturn(unsafeKeystone); + when(pocketBases.state(missingPlayerId)).thenReturn( + new PocketBaseState(missingPlayerId, 1, Optional.empty()) + ); + when(pocketBases.state(unsafePlayerId)).thenReturn( + new PocketBaseState(unsafePlayerId, 1, Optional.empty()) + ); + when(pocketBases.ownerForPocketWorld(missingPocketWorldId)) + .thenReturn(Optional.of(missingPlayerId)); + when(pocketBases.ownerForPocketWorld(unsafePocketWorldId)) + .thenReturn(Optional.of(unsafePlayerId)); + when(returns.destination(missingPlayerId)).thenReturn(Optional.of( + new KeystoneReturnLocation( + missingWorldId, "missing", 0.5, 65.0, 0.5, 0.0F, 0.0F + ) + )); + when(returns.destination(unsafePlayerId)).thenReturn(Optional.of( + new KeystoneReturnLocation( + unsafeWorldId, "unsafe", 0.5, 65.0, 0.5, 0.0F, 0.0F + ) + )); + when(server.getWorld(unsafeWorldId)).thenReturn(unsafeWorld); + when(unsafeWorld.getMinHeight()).thenReturn(-64); + when(unsafeWorld.getMaxHeight()).thenReturn(320); + Block unsafeBlock = mock(Block.class); + when(unsafeBlock.getType()).thenReturn(Material.AIR); + when(unsafeWorld.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenReturn(unsafeBlock); + + service.onUse(missingEvent); + service.onUse(unsafeEvent); + + verify(missingPlayer, never()).teleport( + any(Location.class), any(PlayerTeleportEvent.TeleportCause.class) + ); + verify(unsafePlayer, never()).teleport( + any(Location.class), any(PlayerTeleportEvent.TeleportCause.class) + ); + } + + @Test + void authenticatesKeystonesByPersistentMetadataInsteadOfTheirName() { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + ItemStack marked = mock(ItemStack.class); + ItemMeta markedMeta = mock(ItemMeta.class); + PersistentDataContainer markedData = mock(PersistentDataContainer.class); + ItemStack renamedOnly = mock(ItemStack.class); + ItemMeta renamedMeta = mock(ItemMeta.class); + PersistentDataContainer renamedData = mock(PersistentDataContainer.class); + + when(marked.getType()).thenReturn(Material.RECOVERY_COMPASS); + when(marked.getItemMeta()).thenReturn(markedMeta); + when(markedMeta.getPersistentDataContainer()).thenReturn(markedData); + when(markedData.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1); + when(renamedOnly.getType()).thenReturn(Material.RECOVERY_COMPASS); + when(renamedOnly.getItemMeta()).thenReturn(renamedMeta); + when(renamedMeta.getPersistentDataContainer()).thenReturn(renamedData); + + assertTrue(PocketBaseKeystoneService.isKeystone(marked, key)); + assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key)); + } + + private static ItemStack markedKeystone(NamespacedKey key) { + ItemStack item = mock(ItemStack.class); + ItemMeta metadata = mock(ItemMeta.class); + PersistentDataContainer data = mock(PersistentDataContainer.class); + when(item.getType()).thenReturn(Material.RECOVERY_COMPASS); + when(item.getItemMeta()).thenReturn(metadata); + when(metadata.getPersistentDataContainer()).thenReturn(data); + when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1); + return item; + } +} diff --git a/src/test/java/games/dmg/spigotbase/YamlKeystoneReturnRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlKeystoneReturnRepositoryTest.java new file mode 100644 index 0000000..2108458 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/YamlKeystoneReturnRepositoryTest.java @@ -0,0 +1,29 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class YamlKeystoneReturnRepositoryTest { + @TempDir + Path temporaryDirectory; + + @Test + void roundTripsExactPlayerReturnDestinations() throws Exception { + UUID playerId = UUID.randomUUID(); + KeystoneReturnLocation expected = new KeystoneReturnLocation( + UUID.randomUUID(), "world", 12.25, 70.5, -4.75, 123.0F, -15.5F + ); + YamlKeystoneReturnRepository repository = new YamlKeystoneReturnRepository( + temporaryDirectory.resolve("keystone-returns.yml") + ); + + repository.save(Map.of(playerId, expected)); + + assertEquals(expected, repository.load().get(playerId)); + } +}