feat(pocket-base): add reusable keystone travel
Release / release (push) Successful in 3m2s
CI / build (push) Successful in 1m23s

This commit is contained in:
dmg
2026-08-29 14:43:12 -04:00
parent e0b1a25c5e
commit 61e7a01374
13 changed files with 811 additions and 0 deletions
+2
View File
@@ -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 <hostile|passive> <enable|disable>`. 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 <enable|disable>`. 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|nether|overworld> <subtype>`. 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
+8
View File
@@ -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`.
+1
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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");
}
}
}
@@ -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<UUID, KeystoneReturnLocation> destinations;
KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException {
this.repository = repository;
this.destinations = new HashMap<>(repository.load());
}
Optional<KeystoneReturnLocation> 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;
}
}
}
@@ -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<Material> 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<UUID> 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<KeystoneReturnLocation> 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
) {
}
}
@@ -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
@@ -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<UUID, KeystoneReturnLocation> 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<UUID, KeystoneReturnLocation> 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<UUID, KeystoneReturnLocation> destinations) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
for (Map.Entry<UUID, KeystoneReturnLocation> 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);
}
}
}
@@ -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;
}
}
@@ -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));
}
}