Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69378fb2b | ||
|
|
695c78ea16 |
@@ -79,6 +79,18 @@ public final class BukkitRoleControlItemService implements RoleControlItemServic
|
|||||||
game, player.getUniqueId()
|
game, player.getUniqueId()
|
||||||
);
|
);
|
||||||
boolean retained = false;
|
boolean retained = false;
|
||||||
|
ItemStack cursor = player.getItemOnCursor();
|
||||||
|
if (isBoundRoleControlItem(cursor)) {
|
||||||
|
if (owner(cursor).filter(player.getUniqueId()::equals).isPresent() && role(cursor).equals(required)) {
|
||||||
|
retained = true; // Preserve the copy the owner is actively moving.
|
||||||
|
if (cursor.getAmount() > 1) {
|
||||||
|
cursor.setAmount(1);
|
||||||
|
player.setItemOnCursor(cursor);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.setItemOnCursor(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
ItemStack[] contents = player.getInventory().getContents();
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
for (int index = 0; index < contents.length; index++) {
|
for (int index = 0; index < contents.length; index++) {
|
||||||
ItemStack item = contents[index];
|
ItemStack item = contents[index];
|
||||||
@@ -90,6 +102,10 @@ public final class BukkitRoleControlItemService implements RoleControlItemServic
|
|||||||
&& role(item).equals(required);
|
&& role(item).equals(required);
|
||||||
if (valid) {
|
if (valid) {
|
||||||
retained = true;
|
retained = true;
|
||||||
|
if (item.getAmount() > 1) {
|
||||||
|
item.setAmount(1);
|
||||||
|
player.getInventory().setItem(index, item);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
player.getInventory().setItem(index, null);
|
player.getInventory().setItem(index, null);
|
||||||
}
|
}
|
||||||
@@ -98,6 +114,9 @@ public final class BukkitRoleControlItemService implements RoleControlItemServic
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeAll(Player player) {
|
public void removeAll(Player player) {
|
||||||
|
if (isBoundRoleControlItem(player.getItemOnCursor())) {
|
||||||
|
player.setItemOnCursor(null);
|
||||||
|
}
|
||||||
ItemStack[] contents = player.getInventory().getContents();
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
for (int index = 0; index < contents.length; index++) {
|
for (int index = 0; index < contents.length; index++) {
|
||||||
if (isBoundRoleControlItem(contents[index])) {
|
if (isBoundRoleControlItem(contents[index])) {
|
||||||
@@ -127,6 +146,11 @@ public final class BukkitRoleControlItemService implements RoleControlItemServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean contains(Player player, RoleControl role) {
|
private boolean contains(Player player, RoleControl role) {
|
||||||
|
ItemStack cursor = player.getItemOnCursor();
|
||||||
|
if (owner(cursor).filter(player.getUniqueId()::equals).isPresent()
|
||||||
|
&& role(cursor).filter(role::equals).isPresent()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
for (ItemStack item : player.getInventory().getContents()) {
|
for (ItemStack item : player.getInventory().getContents()) {
|
||||||
if (owner(item).filter(player.getUniqueId()::equals).isPresent()
|
if (owner(item).filter(player.getUniqueId()::equals).isPresent()
|
||||||
&& role(item).filter(role::equals).isPresent()) {
|
&& role(item).filter(role::equals).isPresent()) {
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ public final class CapturedMobInventoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Set<UUID> retained = new HashSet<>();
|
Set<UUID> retained = new HashSet<>();
|
||||||
|
ItemStack cursor = player.getItemOnCursor();
|
||||||
|
if (items.isCapturedMob(cursor)) {
|
||||||
|
UUID captureId = items.captureId(cursor).orElseThrow();
|
||||||
|
if (items.owner(cursor).filter(state.playerId()::equals).isPresent()
|
||||||
|
&& stored.containsKey(captureId) && retained.add(captureId)) {
|
||||||
|
if (cursor.getAmount() > 1) {
|
||||||
|
cursor.setAmount(1);
|
||||||
|
player.setItemOnCursor(cursor);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.setItemOnCursor(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
ItemStack[] contents = player.getInventory().getContents();
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
for (int index = 0; index < contents.length; index++) {
|
for (int index = 0; index < contents.length; index++) {
|
||||||
ItemStack item = contents[index];
|
ItemStack item = contents[index];
|
||||||
@@ -42,6 +55,9 @@ public final class CapturedMobInventoryService {
|
|||||||
&& stored.containsKey(captureId) && retained.add(captureId);
|
&& stored.containsKey(captureId) && retained.add(captureId);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
player.getInventory().setItem(index, null);
|
player.getInventory().setItem(index, null);
|
||||||
|
} else if (item.getAmount() > 1) {
|
||||||
|
item.setAmount(1);
|
||||||
|
player.getInventory().setItem(index, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
|
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
|
||||||
|
|||||||
@@ -89,12 +89,28 @@ public final class RoleControlItemListener implements Listener {
|
|||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
public void onInventoryClick(InventoryClickEvent event) {
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
Player player = event.getWhoClicked() instanceof Player clicked ? clicked : null;
|
||||||
|
ItemStack swapSource = null;
|
||||||
|
if (player != null) {
|
||||||
|
if (event.getHotbarButton() >= 0 && event.getHotbarButton() < 9) {
|
||||||
|
swapSource = player.getInventory().getItem(event.getHotbarButton());
|
||||||
|
} else if (event.getClick() == org.bukkit.event.inventory.ClickType.SWAP_OFFHAND) {
|
||||||
|
swapSource = player.getInventory().getItemInOffHand();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (items.isBoundRoleControlItem(event.getCurrentItem())
|
if (items.isBoundRoleControlItem(event.getCurrentItem())
|
||||||
|| items.isBoundRoleControlItem(event.getCursor())) {
|
|| items.isBoundRoleControlItem(event.getCursor()) || items.isBoundRoleControlItem(swapSource)) {
|
||||||
if (!(event.getWhoClicked() instanceof Player player)
|
var action = event.getAction();
|
||||||
|| event.isShiftClick()
|
boolean scansOtherSlots = event.isShiftClick()
|
||||||
|| event.getClickedInventory() == null
|
|| action == org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY
|
||||||
|| !event.getClickedInventory().equals(player.getInventory())) {
|
|| action == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR;
|
||||||
|
if (player == null || event.getClickedInventory() == null
|
||||||
|
|| !event.getClickedInventory().equals(player.getInventory())
|
||||||
|
|| !validFor(player, event.getCurrentItem()) || !validFor(player, event.getCursor())
|
||||||
|
|| !validFor(player, swapSource)
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.CLONE_STACK
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.UNKNOWN
|
||||||
|
|| scansOtherSlots && !personalCraftingView(event.getView())) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,10 +121,28 @@ public final class RoleControlItemListener implements Listener {
|
|||||||
if (items.isBoundRoleControlItem(event.getOldCursor())
|
if (items.isBoundRoleControlItem(event.getOldCursor())
|
||||||
|| event.getNewItems().values().stream()
|
|| event.getNewItems().values().stream()
|
||||||
.anyMatch(items::isBoundRoleControlItem)) {
|
.anyMatch(items::isBoundRoleControlItem)) {
|
||||||
event.setCancelled(true);
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| !validFor(player, event.getOldCursor())
|
||||||
|
|| event.getNewItems().values().stream().anyMatch(item -> !validFor(player, item))
|
||||||
|
|| event.getRawSlots().stream().anyMatch(raw ->
|
||||||
|
!player.getInventory().equals(event.getView().getInventory(raw)))) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean validFor(Player player, ItemStack item) {
|
||||||
|
return !items.isBoundRoleControlItem(item)
|
||||||
|
|| items.owner(item).filter(player.getUniqueId()::equals).isPresent()
|
||||||
|
&& items.role(item).equals(RoleControlItemPolicy.requiredRole(stateManager.game(), player.getUniqueId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean personalCraftingView(org.bukkit.inventory.InventoryView view) {
|
||||||
|
// The native player view has a 2x2 matrix; a workbench has 3x3 and is an external destination.
|
||||||
|
return view.getTopInventory() instanceof org.bukkit.inventory.CraftingInventory crafting
|
||||||
|
&& crafting.getMatrix().length == 4;
|
||||||
|
}
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
public void onInventoryMove(InventoryMoveItemEvent event) {
|
public void onInventoryMove(InventoryMoveItemEvent event) {
|
||||||
if (items.isBoundRoleControlItem(event.getItem())) {
|
if (items.isBoundRoleControlItem(event.getItem())) {
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new TamerListener(
|
new TamerListener(
|
||||||
stateManager, abilityItems, capturedMobItems, capturedMobs,
|
stateManager, abilityItems, capturedMobItems, capturedMobs,
|
||||||
settings, getServer()
|
settings, getServer(), task -> getServer().getScheduler().runTask(this, task)
|
||||||
),
|
),
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public final class TamerListener implements Listener {
|
|||||||
private final CapturedMobService capturedMobs;
|
private final CapturedMobService capturedMobs;
|
||||||
private final PluginSettings settings;
|
private final PluginSettings settings;
|
||||||
private final Server server;
|
private final Server server;
|
||||||
|
private final java.util.function.Consumer<Runnable> nextTick;
|
||||||
|
private final java.util.Set<UUID> pendingInventoryRefresh = new java.util.HashSet<>();
|
||||||
|
|
||||||
public TamerListener(
|
public TamerListener(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
@@ -40,7 +42,8 @@ public final class TamerListener implements Listener {
|
|||||||
CapturedMobItemService capturedItems,
|
CapturedMobItemService capturedItems,
|
||||||
CapturedMobService capturedMobs,
|
CapturedMobService capturedMobs,
|
||||||
PluginSettings settings,
|
PluginSettings settings,
|
||||||
Server server
|
Server server,
|
||||||
|
java.util.function.Consumer<Runnable> nextTick
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.abilityItems = abilityItems;
|
this.abilityItems = abilityItems;
|
||||||
@@ -48,6 +51,7 @@ public final class TamerListener implements Listener {
|
|||||||
this.capturedMobs = capturedMobs;
|
this.capturedMobs = capturedMobs;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
|
this.nextTick = nextTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.HIGH)
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
@@ -162,15 +166,82 @@ public final class TamerListener implements Listener {
|
|||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
public void onInventoryClick(InventoryClickEvent event) {
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
if (capturedItems.isCapturedMob(event.getCurrentItem())
|
Player player = event.getWhoClicked() instanceof Player clicked ? clicked : null;
|
||||||
|| capturedItems.isCapturedMob(event.getCursor())) {
|
ItemStack swapSource = null;
|
||||||
if (!(event.getWhoClicked() instanceof Player player)
|
if (player != null) {
|
||||||
|| event.isShiftClick()
|
if (event.getHotbarButton() >= 0 && event.getHotbarButton() < 9) {
|
||||||
|| event.getClickedInventory() == null
|
swapSource = player.getInventory().getItem(event.getHotbarButton());
|
||||||
|| !event.getClickedInventory().equals(player.getInventory())) {
|
} else if (event.getClick() == org.bukkit.event.inventory.ClickType.SWAP_OFFHAND) {
|
||||||
event.setCancelled(true);
|
swapSource = player.getInventory().getItemInOffHand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (capturedItems.isCapturedMob(event.getCurrentItem())
|
||||||
|
|| capturedItems.isCapturedMob(event.getCursor()) || capturedItems.isCapturedMob(swapSource)) {
|
||||||
|
var action = event.getAction();
|
||||||
|
if (player == null || event.isCancelled() || event.isShiftClick()
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.CLONE_STACK
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR
|
||||||
|
|| action == org.bukkit.event.inventory.InventoryAction.UNKNOWN
|
||||||
|
|| event.getClickedInventory() == null
|
||||||
|
|| !event.getClickedInventory().equals(player.getInventory())
|
||||||
|
|| !ownedBy(player, event.getCurrentItem()) || !ownedBy(player, event.getCursor())
|
||||||
|
|| !ownedBy(player, swapSource)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
if (player != null) {
|
||||||
|
refreshInventory(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryDrag(org.bukkit.event.inventory.InventoryDragEvent event) {
|
||||||
|
if (!capturedItems.isCapturedMob(event.getOldCursor())
|
||||||
|
&& event.getNewItems().values().stream().noneMatch(capturedItems::isCapturedMob)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = event.getWhoClicked() instanceof Player dragged ? dragged : null;
|
||||||
|
if (player == null || event.isCancelled() || !ownedBy(player, event.getOldCursor())
|
||||||
|
|| event.getNewItems().values().stream().anyMatch(item -> !ownedBy(player, item))
|
||||||
|
|| event.getRawSlots().stream().anyMatch(raw ->
|
||||||
|
!player.getInventory().equals(event.getView().getInventory(raw)))) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
if (player != null) {
|
||||||
|
refreshInventory(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryMove(org.bukkit.event.inventory.InventoryMoveItemEvent event) {
|
||||||
|
if (capturedItems.isCapturedMob(event.getItem())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ownedBy(Player player, ItemStack item) {
|
||||||
|
return !capturedItems.isCapturedMob(item)
|
||||||
|
|| capturedItems.owner(item).filter(player.getUniqueId()::equals).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshInventory(Player player) {
|
||||||
|
UUID id = player.getUniqueId();
|
||||||
|
if (!pendingInventoryRefresh.add(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The native click transaction must finish before forcing its authoritative contents to the client.
|
||||||
|
try {
|
||||||
|
nextTick.accept(() -> {
|
||||||
|
pendingInventoryRefresh.remove(id);
|
||||||
|
if (player.isOnline()) {
|
||||||
|
player.updateInventory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
pendingInventoryRefresh.remove(id);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource;
|
||||||
|
|
||||||
|
class BukkitRoleControlItemServiceTest {
|
||||||
|
private final UUID owner = UUID.randomUUID();
|
||||||
|
private final Player player = mock(Player.class);
|
||||||
|
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
private final ItemStack[] contents = new ItemStack[41];
|
||||||
|
private final AtomicReference<ItemStack> cursor = new AtomicReference<>();
|
||||||
|
private BukkitRoleControlItemService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
Plugin plugin = mock(Plugin.class);
|
||||||
|
when(plugin.getName()).thenReturn("SpigotTyrant");
|
||||||
|
service = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
|
||||||
|
when(player.getUniqueId()).thenReturn(owner);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.getContents()).thenAnswer(call -> contents.clone());
|
||||||
|
doAnswer(call -> { contents[call.getArgument(0, Integer.class)] = call.getArgument(1); return null; })
|
||||||
|
.when(inventory).setItem(anyInt(), nullable(ItemStack.class));
|
||||||
|
when(player.getItemOnCursor()).thenAnswer(call -> cursor.get());
|
||||||
|
doAnswer(call -> { cursor.set(call.getArgument(0)); return null; })
|
||||||
|
.when(player).setItemOnCursor(nullable(ItemStack.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void recoveryDoesNotDuplicateOrRepositionCursorHeldControlItem(RoleControl role) {
|
||||||
|
ItemStack held = item(owner, role);
|
||||||
|
cursor.set(held);
|
||||||
|
try (var created = mockConstruction(ItemStack.class, (item, context) -> {
|
||||||
|
ItemMeta meta = mock(ItemMeta.class);
|
||||||
|
when(item.getItemMeta()).thenReturn(meta);
|
||||||
|
when(meta.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class));
|
||||||
|
})) {
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++) {
|
||||||
|
service.recover(player, game(role));
|
||||||
|
}
|
||||||
|
assertEquals(0, created.constructed().size(), "Cursor-held item already satisfies recovery");
|
||||||
|
assertSame(held, cursor.get());
|
||||||
|
verify(inventory, never()).addItem(any(ItemStack[].class));
|
||||||
|
verify(inventory, never()).setItem(anyInt(), nullable(ItemStack.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void refreshPreservesCursorChoiceAndRemovesExtraInventoryCopies(RoleControl role) {
|
||||||
|
ItemStack held = item(owner, role);
|
||||||
|
cursor.set(held);
|
||||||
|
contents[5] = item(owner, role);
|
||||||
|
ItemStack ordinary = mock(ItemStack.class);
|
||||||
|
contents[8] = ordinary;
|
||||||
|
var manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(game(role));
|
||||||
|
var server = mock(org.bukkit.Server.class);
|
||||||
|
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
var refresh = new RoleControlItemRefreshTask(manager, service, server);
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++) {
|
||||||
|
refresh.run();
|
||||||
|
}
|
||||||
|
assertSame(held, cursor.get());
|
||||||
|
assertNull(contents[5], "Keep the actively moved cursor copy, not an old recovery duplicate");
|
||||||
|
assertSame(ordinary, contents[8]);
|
||||||
|
verify(inventory, never()).addItem(any(ItemStack[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void roleLossRemovesCursorAndInventoryCopies(RoleControl role) {
|
||||||
|
cursor.set(item(owner, role));
|
||||||
|
contents[5] = item(owner, role);
|
||||||
|
service.removeInvalid(player, game(null));
|
||||||
|
assertNull(cursor.get());
|
||||||
|
assertNull(contents[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void explicitRemovalIncludesCursorWithoutTouchingOrdinaryItems(RoleControl role) {
|
||||||
|
cursor.set(item(owner, role));
|
||||||
|
contents[5] = item(owner, role);
|
||||||
|
ItemStack ordinary = mock(ItemStack.class);
|
||||||
|
contents[8] = ordinary;
|
||||||
|
service.removeAll(player);
|
||||||
|
assertNull(cursor.get());
|
||||||
|
assertNull(contents[5]);
|
||||||
|
assertSame(ordinary, contents[8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void mergedDuplicateStacksAreReducedWithoutMovingTheCanonicalItem(RoleControl role) {
|
||||||
|
for (boolean heldOnCursor : new boolean[] {false, true}) {
|
||||||
|
java.util.Arrays.fill(contents, null);
|
||||||
|
cursor.set(null);
|
||||||
|
ItemStack held = item(owner, role);
|
||||||
|
held.setAmount(2);
|
||||||
|
if (heldOnCursor) { cursor.set(held); } else { contents[5] = held; }
|
||||||
|
service.removeInvalid(player, game(role));
|
||||||
|
assertEquals(1, held.getAmount(), "Recovery duplicates may have merged into one stack");
|
||||||
|
assertSame(held, heldOnCursor ? cursor.get() : contents[5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack item(UUID playerId, RoleControl role) {
|
||||||
|
ItemStack item = mock(ItemStack.class);
|
||||||
|
ItemMeta meta = mock(ItemMeta.class);
|
||||||
|
PersistentDataContainer data = mock(PersistentDataContainer.class);
|
||||||
|
when(item.hasItemMeta()).thenReturn(true);
|
||||||
|
when(item.getItemMeta()).thenReturn(meta);
|
||||||
|
var amount = new java.util.concurrent.atomic.AtomicInteger(1);
|
||||||
|
when(item.getAmount()).thenAnswer(call -> amount.get());
|
||||||
|
doAnswer(call -> { amount.set(call.getArgument(0)); return null; }).when(item).setAmount(anyInt());
|
||||||
|
when(meta.getPersistentDataContainer()).thenReturn(data);
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "role-control"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(role.name());
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "role-control-owner"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(playerId.toString());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameState game(RoleControl role) {
|
||||||
|
return new GameState(GameLifecycle.RUNNING,
|
||||||
|
role == RoleControl.TYRANT ? Optional.of(owner) : Optional.empty(),
|
||||||
|
role == RoleControl.VIGILANTE ? Optional.of(owner) : Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.inventory.ClickType;
|
||||||
|
import org.bukkit.event.inventory.InventoryAction;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryType;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryView;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class CapturedMobInventorySynchronizationTest {
|
||||||
|
@TempDir Path directory;
|
||||||
|
private final UUID owner = UUID.randomUUID(), captureId = UUID.randomUUID();
|
||||||
|
private final Player player = mock(Player.class);
|
||||||
|
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
private final Inventory top = mock(Inventory.class);
|
||||||
|
private final InventoryView view = mock(InventoryView.class);
|
||||||
|
private final Server server = mock(Server.class);
|
||||||
|
private final ItemStack[] contents = new ItemStack[41];
|
||||||
|
private final AtomicReference<ItemStack> cursor = new AtomicReference<>();
|
||||||
|
private final List<Runnable> scheduled = new ArrayList<>();
|
||||||
|
private TyrantStateManager manager;
|
||||||
|
private CapturedMobItemService items;
|
||||||
|
private TamerListener listener;
|
||||||
|
private ItemStack egg;
|
||||||
|
private Path stateFile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() throws Exception {
|
||||||
|
when(player.getUniqueId()).thenReturn(owner);
|
||||||
|
when(player.getName()).thenReturn("Tamer");
|
||||||
|
when(player.isOnline()).thenReturn(true);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(player.getItemOnCursor()).thenAnswer(call -> cursor.get());
|
||||||
|
doAnswer(call -> { cursor.set(call.getArgument(0)); return null; })
|
||||||
|
.when(player).setItemOnCursor(nullable(ItemStack.class));
|
||||||
|
when(inventory.getContents()).thenAnswer(call -> contents.clone());
|
||||||
|
when(inventory.getItem(anyInt())).thenAnswer(call -> contents[call.getArgument(0, Integer.class)]);
|
||||||
|
doAnswer(call -> { contents[call.getArgument(0, Integer.class)] = call.getArgument(1); return null; })
|
||||||
|
.when(inventory).setItem(anyInt(), nullable(ItemStack.class));
|
||||||
|
when(view.getPlayer()).thenReturn(player);
|
||||||
|
when(view.getBottomInventory()).thenReturn(inventory);
|
||||||
|
when(view.getTopInventory()).thenReturn(top);
|
||||||
|
when(top.getSize()).thenReturn(27);
|
||||||
|
when(top.getContents()).thenReturn(new ItemStack[27]);
|
||||||
|
when(view.getInventory(anyInt())).thenAnswer(call -> {
|
||||||
|
int raw = call.getArgument(0);
|
||||||
|
return raw < 0 || raw >= 68 ? null : raw < 27 ? top : inventory;
|
||||||
|
});
|
||||||
|
when(view.convertSlot(anyInt())).thenAnswer(call -> call.getArgument(0, Integer.class) - 27);
|
||||||
|
when(view.getItem(anyInt())).thenAnswer(call -> {
|
||||||
|
int raw = call.getArgument(0);
|
||||||
|
return raw >= 27 && raw < 68 ? contents[raw - 27] : null;
|
||||||
|
});
|
||||||
|
when(view.getCursor()).thenAnswer(call -> cursor.get());
|
||||||
|
Plugin plugin = mock(Plugin.class);
|
||||||
|
when(plugin.getName()).thenReturn("SpigotTyrant");
|
||||||
|
items = new BukkitCapturedMobItemService(plugin, PluginSettings.from(Map.of()));
|
||||||
|
egg = egg(owner, captureId);
|
||||||
|
contents[3] = egg;
|
||||||
|
stateFile = directory.resolve("state.yml");
|
||||||
|
manager = new TyrantStateManager(new YamlTyrantStateRepository(stateFile), Logger.getAnonymousLogger());
|
||||||
|
var mob = new CapturedMob("COW", Map.of("capture-id", captureId.toString(), "snapshot", "stored-cow"));
|
||||||
|
var state = new PlayerState(owner, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
|
||||||
|
Optional.empty(), Map.of(), Set.of(), List.of(mob));
|
||||||
|
manager.updatePlayer(owner, "Tamer", ignored -> state);
|
||||||
|
assertTrue(manager.saveIfDirty());
|
||||||
|
listener = new TamerListener(manager, mock(AbilityItemService.class), items,
|
||||||
|
new CapturedMobService(), PluginSettings.from(Map.of()), server, scheduled::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedShiftKeepsAuthoritativeEggAndCustodyAndResendsAfterTransaction() throws Exception {
|
||||||
|
var before = manager.snapshot();
|
||||||
|
byte[] bytes = Files.readAllBytes(stateFile);
|
||||||
|
var event = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
assertSame(egg, contents[3]);
|
||||||
|
assertTrue(java.util.Arrays.stream(top.getContents()).allMatch(java.util.Objects::isNull));
|
||||||
|
verify(player, never()).updateInventory();
|
||||||
|
assertEquals(1, scheduled.size(), "A rejected transfer needs a post-transaction full inventory refresh");
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
verify(player).updateInventory();
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
|
||||||
|
assertEquals(1, new YamlTyrantStateRepository(stateFile).load().players().get(owner).capturedMobs().size());
|
||||||
|
verify(server, never()).getEntityFactory();
|
||||||
|
verify(inventory, never()).setItem(anyInt(), nullable(ItemStack.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedRejectedTransfersCoalesceRefreshWithoutChangingEggOrCustody() throws Exception {
|
||||||
|
var before = manager.snapshot();
|
||||||
|
byte[] bytes = Files.readAllBytes(stateFile);
|
||||||
|
for (int tick = 0; tick < 3; tick++) {
|
||||||
|
for (int attempt = 0; attempt < 5; attempt++) {
|
||||||
|
var event = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
assertSame(egg, contents[3]);
|
||||||
|
}
|
||||||
|
assertEquals(1, scheduled.size(), "Repeated rejects in one tick need one authoritative refresh");
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
}
|
||||||
|
verify(player, times(3)).updateInventory();
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
|
||||||
|
assertEquals(1, egg.getAmount());
|
||||||
|
assertTrue(java.util.Arrays.stream(top.getContents()).allMatch(java.util.Objects::isNull));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutBeforeRefreshDoesNotMutateOrReissueAnything() {
|
||||||
|
var before = manager.snapshot();
|
||||||
|
listener.onInventoryClick(click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1));
|
||||||
|
when(player.isOnline()).thenReturn(false);
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
verify(player, never()).updateInventory();
|
||||||
|
assertSame(egg, contents[3]);
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedSchedulingDoesNotPermanentlySuppressLaterRefresh() {
|
||||||
|
int[] attempts = {0};
|
||||||
|
listener = new TamerListener(manager, mock(AbilityItemService.class), items,
|
||||||
|
new CapturedMobService(), PluginSettings.from(Map.of()), server, task -> {
|
||||||
|
if (attempts[0]++ == 0) { throw new IllegalStateException("scheduler unavailable"); }
|
||||||
|
scheduled.add(task);
|
||||||
|
});
|
||||||
|
var first = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
assertThrows(IllegalStateException.class, () -> listener.onInventoryClick(first));
|
||||||
|
assertTrue(first.isCancelled());
|
||||||
|
listener.onInventoryClick(click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1));
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
verify(player).updateInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoveryDoesNotDuplicateAnEggHeldOnTheCursor() {
|
||||||
|
contents[3] = null;
|
||||||
|
cursor.set(egg);
|
||||||
|
when(inventory.addItem(any(ItemStack[].class))).thenAnswer(call -> {
|
||||||
|
ItemStack[] added = (ItemStack[]) call.getRawArguments()[0];
|
||||||
|
for (ItemStack item : added) {
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
if (contents[index] == null) { contents[index] = item; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new java.util.HashMap<Integer, ItemStack>();
|
||||||
|
});
|
||||||
|
var before = manager.snapshot();
|
||||||
|
var recovery = new CapturedMobInventoryService(items, PluginSettings.from(Map.of()));
|
||||||
|
try (var constructed = mockConstruction(ItemStack.class, (item, context) -> markEgg(item, owner, captureId))) {
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++) {
|
||||||
|
recovery.reconcile(player, manager.player(owner, "Tamer"));
|
||||||
|
}
|
||||||
|
assertEquals(0, constructed.constructed().size(), "Cursor-held egg already represents the stored mob");
|
||||||
|
assertTrue(java.util.Arrays.stream(contents).allMatch(java.util.Objects::isNull));
|
||||||
|
assertSame(egg, cursor.get());
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reconciliationKeepsCursorCopyAndRemovesOnlyDuplicateEggs() {
|
||||||
|
cursor.set(egg);
|
||||||
|
egg.setAmount(2);
|
||||||
|
contents[3] = egg(owner, captureId);
|
||||||
|
ItemStack ordinary = mock(ItemStack.class);
|
||||||
|
contents[4] = ordinary;
|
||||||
|
var before = manager.snapshot();
|
||||||
|
new CapturedMobInventoryService(items, PluginSettings.from(Map.of()))
|
||||||
|
.reconcile(player, manager.player(owner, "Tamer"));
|
||||||
|
assertSame(egg, cursor.get());
|
||||||
|
assertEquals(1, egg.getAmount());
|
||||||
|
assertNull(contents[3]);
|
||||||
|
assertSame(ordinary, contents[4]);
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cursorEggIsRemovedOnlyWhenCustodyIsGoneOrOwnershipIsInvalid() {
|
||||||
|
contents[3] = null;
|
||||||
|
cursor.set(egg);
|
||||||
|
var released = new CapturedMobService().release(manager.player(owner, "Tamer"), captureId);
|
||||||
|
var recovery = new CapturedMobInventoryService(items, PluginSettings.from(Map.of()));
|
||||||
|
recovery.reconcile(player, released);
|
||||||
|
assertNull(cursor.get(), "A released mob must not leave a cursor token behind");
|
||||||
|
contents[3] = egg;
|
||||||
|
cursor.set(egg(UUID.randomUUID(), captureId));
|
||||||
|
recovery.reconcile(player, manager.player(owner, "Tamer"));
|
||||||
|
assertNull(cursor.get(), "Foreign items cannot be retained");
|
||||||
|
assertSame(egg, contents[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenHotbarTransferIsRejectedAndResynchronized() {
|
||||||
|
contents[3] = null;
|
||||||
|
contents[2] = egg;
|
||||||
|
var event = click(0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2);
|
||||||
|
listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
verify(player).updateInventory();
|
||||||
|
assertSame(egg, contents[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenOffhandTransferIsRejectedAndResynchronized() {
|
||||||
|
contents[3] = null;
|
||||||
|
contents[40] = egg;
|
||||||
|
when(inventory.getItemInOffHand()).thenReturn(egg);
|
||||||
|
var event = click(0, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1);
|
||||||
|
listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void crossInventoryDragIsRejectedAndResynchronized() {
|
||||||
|
cursor.set(egg);
|
||||||
|
contents[3] = null;
|
||||||
|
var event = new org.bukkit.event.inventory.InventoryDragEvent(view, null, egg, false, Map.of(0, egg, 30, egg));
|
||||||
|
listener.onInventoryDrag(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
assertSame(egg, cursor.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void automatedTransferCannotMoveCapturedEggs() {
|
||||||
|
var hopper = new org.bukkit.event.inventory.InventoryMoveItemEvent(top, egg, mock(Inventory.class), true);
|
||||||
|
listener.onInventoryMove(hopper);
|
||||||
|
assertTrue(hopper.isCancelled());
|
||||||
|
assertTrue(scheduled.isEmpty(), "Automated moves have no predicting player to refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cloningCannotCreateAdditionalEggCopies() {
|
||||||
|
var clone = click(30, ClickType.MIDDLE, InventoryAction.CLONE_STACK, -1);
|
||||||
|
listener.onInventoryClick(clone);
|
||||||
|
assertTrue(clone.isCancelled());
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryItemsRemainUnaffectedWithoutRefreshTraffic() {
|
||||||
|
ItemStack ordinary = mock(ItemStack.class);
|
||||||
|
when(ordinary.clone()).thenReturn(ordinary);
|
||||||
|
contents[3] = ordinary;
|
||||||
|
var shift = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
listener.onInventoryClick(shift);
|
||||||
|
assertFalse(shift.isCancelled());
|
||||||
|
var drag = new org.bukkit.event.inventory.InventoryDragEvent(view, null, ordinary, false, Map.of(0, ordinary));
|
||||||
|
listener.onInventoryDrag(drag);
|
||||||
|
assertFalse(drag.isCancelled());
|
||||||
|
var hopper = new org.bukkit.event.inventory.InventoryMoveItemEvent(top, ordinary, mock(Inventory.class), true);
|
||||||
|
listener.onInventoryMove(hopper);
|
||||||
|
assertFalse(hopper.isCancelled());
|
||||||
|
assertTrue(scheduled.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void safeInternalCursorMovementDoesNotTriggerRejectionOrRefresh() {
|
||||||
|
var pickup = click(30, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1);
|
||||||
|
listener.onInventoryClick(pickup);
|
||||||
|
assertFalse(pickup.isCancelled());
|
||||||
|
contents[3] = null;
|
||||||
|
cursor.set(egg);
|
||||||
|
var place = click(31, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
|
||||||
|
listener.onInventoryClick(place);
|
||||||
|
assertFalse(place.isCancelled());
|
||||||
|
var drag = new org.bukkit.event.inventory.InventoryDragEvent(view, null, egg, false, Map.of(31, egg));
|
||||||
|
listener.onInventoryDrag(drag);
|
||||||
|
assertFalse(drag.isCancelled());
|
||||||
|
assertTrue(scheduled.isEmpty());
|
||||||
|
new CapturedMobInventoryService(items, PluginSettings.from(Map.of()))
|
||||||
|
.reconcile(player, manager.player(owner, "Tamer"));
|
||||||
|
assertSame(egg, cursor.get());
|
||||||
|
verify(inventory, never()).addItem(any(ItemStack[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cursorTransferAndExistingCancellationPreserveCustodyAndResynchronize() throws Exception {
|
||||||
|
var before = manager.snapshot();
|
||||||
|
byte[] bytes = Files.readAllBytes(stateFile);
|
||||||
|
contents[3] = null;
|
||||||
|
cursor.set(egg);
|
||||||
|
var external = click(0, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
|
||||||
|
listener.onInventoryClick(external);
|
||||||
|
assertTrue(external.isCancelled());
|
||||||
|
var cancelled = click(31, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
|
||||||
|
cancelled.setCancelled(true);
|
||||||
|
listener.onInventoryClick(cancelled);
|
||||||
|
assertTrue(cancelled.isCancelled());
|
||||||
|
assertEquals(1, scheduled.size());
|
||||||
|
scheduled.remove(0).run();
|
||||||
|
assertSame(egg, cursor.get());
|
||||||
|
assertEquals(before, manager.snapshot());
|
||||||
|
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
|
||||||
|
verify(player).updateInventory();
|
||||||
|
verify(top, never()).setItem(anyInt(), nullable(ItemStack.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InventoryClickEvent click(int raw, ClickType click, InventoryAction action, int button) {
|
||||||
|
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, raw, click, action, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack egg(UUID id, UUID captured) {
|
||||||
|
ItemStack item = mock(ItemStack.class);
|
||||||
|
markEgg(item, id, captured);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markEgg(ItemStack item, UUID id, UUID captured) {
|
||||||
|
ItemMeta meta = mock(ItemMeta.class);
|
||||||
|
PersistentDataContainer data = mock(PersistentDataContainer.class);
|
||||||
|
when(item.clone()).thenReturn(item);
|
||||||
|
when(item.hasItemMeta()).thenReturn(true);
|
||||||
|
when(item.getItemMeta()).thenReturn(meta);
|
||||||
|
var amount = new java.util.concurrent.atomic.AtomicInteger(1);
|
||||||
|
when(item.getAmount()).thenAnswer(call -> amount.get());
|
||||||
|
doAnswer(call -> { amount.set(call.getArgument(0)); return null; }).when(item).setAmount(anyInt());
|
||||||
|
when(meta.getPersistentDataContainer()).thenReturn(data);
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "captured-owner"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(id.toString());
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "captured-id"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(captured.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.inventory.ClickType;
|
||||||
|
import org.bukkit.event.inventory.InventoryAction;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryType;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryView;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource;
|
||||||
|
|
||||||
|
class RoleControlInventoryMovementTest {
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void ownerCanShiftBetweenOwnInventorySections(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, true);
|
||||||
|
f.slots.put(10, f.bound(f.owner, role));
|
||||||
|
var event = f.click(10, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
f.listener.onInventoryClick(event);
|
||||||
|
assertFalse(event.isCancelled(), "Personal inventory shift stays in owner inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void ownerCanDragAcrossOnlyTheirInventorySlots(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, false);
|
||||||
|
ItemStack item = f.bound(f.owner, role);
|
||||||
|
var event = new InventoryDragEvent(f.view, null, item, false, Map.of(30, item));
|
||||||
|
f.listener.onInventoryDrag(event);
|
||||||
|
assertFalse(event.isCancelled(), "An open chest must not prevent safe bottom-inventory drag");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void boundHotbarSourceCannotBeSwappedIntoAContainer(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, false);
|
||||||
|
ItemStack source = f.bound(f.owner, role);
|
||||||
|
when(f.inventory.getItem(2)).thenReturn(source);
|
||||||
|
f.slots.put(0, mock(ItemStack.class));
|
||||||
|
var event = f.click(0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2);
|
||||||
|
f.listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled(), "Inspect the hotbar source, not only clicked item/cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void boundOffhandSourceCannotBeSwappedIntoAContainer(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, false);
|
||||||
|
ItemStack source = f.bound(f.owner, role);
|
||||||
|
when(f.inventory.getItemInOffHand()).thenReturn(source);
|
||||||
|
var event = f.click(0, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1);
|
||||||
|
f.listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void creativeCloneDoesNotCreateTransferableOrExtraRoleItems(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, true);
|
||||||
|
f.slots.put(10, f.bound(f.owner, role));
|
||||||
|
var event = f.click(10, ClickType.MIDDLE, InventoryAction.CLONE_STACK, -1);
|
||||||
|
f.listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void ordinaryCursorMovesAndInternalHotbarSwapsStayAllowed(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, false);
|
||||||
|
ItemStack bound = f.bound(f.owner, role);
|
||||||
|
f.slots.put(30, bound);
|
||||||
|
var pickup = f.click(30, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1);
|
||||||
|
f.listener.onInventoryClick(pickup);
|
||||||
|
assertFalse(pickup.isCancelled());
|
||||||
|
f.slots.remove(30);
|
||||||
|
f.cursor.set(bound);
|
||||||
|
var place = f.click(31, ClickType.RIGHT, InventoryAction.PLACE_ONE, -1);
|
||||||
|
f.listener.onInventoryClick(place);
|
||||||
|
assertFalse(place.isCancelled());
|
||||||
|
f.cursor.set(null);
|
||||||
|
when(f.inventory.getItem(2)).thenReturn(bound);
|
||||||
|
var swap = f.click(32, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2);
|
||||||
|
f.listener.onInventoryClick(swap);
|
||||||
|
assertFalse(swap.isCancelled());
|
||||||
|
when(f.inventory.getItemInOffHand()).thenReturn(bound);
|
||||||
|
var offhand = f.click(32, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1);
|
||||||
|
f.listener.onInventoryClick(offhand);
|
||||||
|
assertFalse(offhand.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void crossInventoryShiftsDragsAndCollectionAreBlocked(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, false);
|
||||||
|
ItemStack bound = f.bound(f.owner, role);
|
||||||
|
f.slots.put(30, bound);
|
||||||
|
var shift = f.click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
f.listener.onInventoryClick(shift);
|
||||||
|
assertTrue(shift.isCancelled());
|
||||||
|
f.cursor.set(bound);
|
||||||
|
var place = f.click(0, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
|
||||||
|
f.listener.onInventoryClick(place);
|
||||||
|
assertTrue(place.isCancelled());
|
||||||
|
var drag = new InventoryDragEvent(f.view, null, bound, false, Map.of(30, bound, 0, bound));
|
||||||
|
f.listener.onInventoryDrag(drag);
|
||||||
|
assertTrue(drag.isCancelled());
|
||||||
|
var collect = f.click(30, ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR, -1);
|
||||||
|
f.listener.onInventoryClick(collect);
|
||||||
|
assertTrue(collect.isCancelled(), "Collection also scans the external inventory");
|
||||||
|
var outside = f.click(-999, ClickType.LEFT, InventoryAction.DROP_ALL_CURSOR, -1);
|
||||||
|
f.listener.onInventoryClick(outside);
|
||||||
|
assertTrue(outside.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void foreignControlItemsCannotBeRetainedThroughMovement(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, true);
|
||||||
|
ItemStack foreign = f.bound(UUID.randomUUID(), role);
|
||||||
|
f.slots.put(10, foreign);
|
||||||
|
var pickup = f.click(10, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1);
|
||||||
|
f.listener.onInventoryClick(pickup);
|
||||||
|
assertTrue(pickup.isCancelled());
|
||||||
|
f.slots.clear();
|
||||||
|
when(f.inventory.getItem(2)).thenReturn(foreign);
|
||||||
|
var swap = f.click(10, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2);
|
||||||
|
f.listener.onInventoryClick(swap);
|
||||||
|
assertTrue(swap.isCancelled());
|
||||||
|
var drag = new InventoryDragEvent(f.view, null, foreign, false, Map.of(10, foreign));
|
||||||
|
f.listener.onInventoryDrag(drag);
|
||||||
|
assertTrue(drag.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void craftingGridAndWorkbenchRemainExternalDestinations(RoleControl role) {
|
||||||
|
Fixture own = new Fixture(role, true);
|
||||||
|
own.cursor.set(own.bound(own.owner, role));
|
||||||
|
var grid = own.click(1, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
|
||||||
|
own.listener.onInventoryClick(grid);
|
||||||
|
assertTrue(grid.isCancelled());
|
||||||
|
Fixture bench = new Fixture(role, 9);
|
||||||
|
bench.slots.put(15, bench.bound(bench.owner, role));
|
||||||
|
var shift = bench.click(15, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
bench.listener.onInventoryClick(shift);
|
||||||
|
assertTrue(shift.isCancelled(), "Only the personal 2x2 view has internal shift routing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(RoleControl.class)
|
||||||
|
void cancelledSafeMovesStayCancelledAndOrdinaryItemsAreUnaffected(RoleControl role) {
|
||||||
|
Fixture f = new Fixture(role, true);
|
||||||
|
f.slots.put(10, f.bound(f.owner, role));
|
||||||
|
var event = f.click(10, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1);
|
||||||
|
event.setCancelled(true);
|
||||||
|
f.listener.onInventoryClick(event);
|
||||||
|
assertTrue(event.isCancelled());
|
||||||
|
Fixture chest = new Fixture(role, false);
|
||||||
|
ItemStack ordinary = mock(ItemStack.class);
|
||||||
|
when(ordinary.clone()).thenReturn(ordinary);
|
||||||
|
chest.slots.put(30, ordinary);
|
||||||
|
var shift = chest.click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||||
|
chest.listener.onInventoryClick(shift);
|
||||||
|
assertFalse(shift.isCancelled());
|
||||||
|
var drag = new InventoryDragEvent(chest.view, null, ordinary, false, Map.of(0, ordinary));
|
||||||
|
chest.listener.onInventoryDrag(drag);
|
||||||
|
assertFalse(drag.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Fixture {
|
||||||
|
private final UUID owner = UUID.randomUUID();
|
||||||
|
private final Player player = mock(Player.class);
|
||||||
|
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
private final InventoryView view = mock(InventoryView.class);
|
||||||
|
private final Map<Integer, ItemStack> slots = new HashMap<>();
|
||||||
|
private final AtomicReference<ItemStack> cursor = new AtomicReference<>();
|
||||||
|
private final RoleControlItemListener listener;
|
||||||
|
|
||||||
|
Fixture(RoleControl role, boolean personalCrafting) {
|
||||||
|
this(role, personalCrafting ? 4 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Fixture(RoleControl role, int matrixSize) {
|
||||||
|
int topSize = matrixSize > 0 ? matrixSize + 1 : 27;
|
||||||
|
when(player.getUniqueId()).thenReturn(owner);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(player.getItemOnCursor()).thenAnswer(call -> cursor.get());
|
||||||
|
when(view.getPlayer()).thenReturn(player);
|
||||||
|
when(view.getBottomInventory()).thenReturn(inventory);
|
||||||
|
Inventory top;
|
||||||
|
if (matrixSize > 0) {
|
||||||
|
var crafting = mock(org.bukkit.inventory.CraftingInventory.class);
|
||||||
|
when(crafting.getMatrix()).thenReturn(new ItemStack[matrixSize]);
|
||||||
|
top = crafting;
|
||||||
|
} else {
|
||||||
|
top = mock(Inventory.class);
|
||||||
|
}
|
||||||
|
when(top.getSize()).thenReturn(topSize);
|
||||||
|
when(view.getTopInventory()).thenReturn(top);
|
||||||
|
when(view.getInventory(anyInt())).thenAnswer(call -> {
|
||||||
|
int raw = call.getArgument(0);
|
||||||
|
return raw < 0 || raw >= topSize + 41 ? null : raw < topSize ? top : inventory;
|
||||||
|
});
|
||||||
|
when(view.convertSlot(anyInt())).thenAnswer(call -> call.getArgument(0, Integer.class) - topSize);
|
||||||
|
when(view.getItem(anyInt())).thenAnswer(call -> slots.get(call.getArgument(0)));
|
||||||
|
when(view.getCursor()).thenAnswer(call -> cursor.get());
|
||||||
|
Plugin plugin = mock(Plugin.class);
|
||||||
|
when(plugin.getName()).thenReturn("SpigotTyrant");
|
||||||
|
var items = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
|
||||||
|
var manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(new GameState(GameLifecycle.RUNNING,
|
||||||
|
role == RoleControl.TYRANT ? Optional.of(owner) : Optional.empty(),
|
||||||
|
role == RoleControl.VIGILANTE ? Optional.of(owner) : Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()));
|
||||||
|
listener = new RoleControlItemListener(manager, items, mock(TyrantControlPanel.class),
|
||||||
|
mock(VigilanteControlPanel.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryClickEvent click(int raw, ClickType click, InventoryAction action, int hotbar) {
|
||||||
|
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, raw, click, action, hotbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack bound(UUID id, RoleControl role) {
|
||||||
|
ItemStack item = mock(ItemStack.class);
|
||||||
|
ItemMeta meta = mock(ItemMeta.class);
|
||||||
|
PersistentDataContainer data = mock(PersistentDataContainer.class);
|
||||||
|
when(item.clone()).thenReturn(item);
|
||||||
|
when(item.hasItemMeta()).thenReturn(true);
|
||||||
|
when(item.getItemMeta()).thenReturn(meta);
|
||||||
|
when(meta.getPersistentDataContainer()).thenReturn(data);
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "role-control"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(role.name());
|
||||||
|
when(data.get(new NamespacedKey("spigottyrant", "role-control-owner"), PersistentDataType.STRING))
|
||||||
|
.thenReturn(id.toString());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ final class TamerListenerTest {
|
|||||||
CapturedMobItemService capturedItems = mock(CapturedMobItemService.class);
|
CapturedMobItemService capturedItems = mock(CapturedMobItemService.class);
|
||||||
TamerListener listener = new TamerListener(
|
TamerListener listener = new TamerListener(
|
||||||
manager, abilityItems, capturedItems, new CapturedMobService(),
|
manager, abilityItems, capturedItems, new CapturedMobService(),
|
||||||
PluginSettings.from(Map.of()), mock(Server.class)
|
PluginSettings.from(Map.of()), mock(Server.class), Runnable::run
|
||||||
);
|
);
|
||||||
|
|
||||||
listener.onCapture(event);
|
listener.onCapture(event);
|
||||||
|
|||||||
Reference in New Issue
Block a user