|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|