Compare commits

..
1 Commits
Author SHA1 Message Date
dmg 76f94f6c15 feat(base): add charged Pocket Base sharing compasses
Release / release (push) Successful in 4m43s
CI / build (push) Successful in 3m35s
2026-09-17 23:31:16 -04:00
8 changed files with 1185 additions and 1 deletions

No files matched your search

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