diff --git a/README.md b/README.md index f27a0c3..215a767 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ These features give other TF-Minecraft plugins common building blocks while also - **Player utilities** — shared player commands and resource-pack delivery support everyday server use. - **Resource-pack texture guard** — warns the console and staff when the ItemsAdder pack holds a texture over 16384 px, which stops the whole pack loading on most Intel graphics. - **Required pack on join** — opt-in (`resource-pack.require-on-join`); sends the pack as required and publishes `tfmccore:required-pack` player metadata (`loading`, `ready`, `failed`) for RPCharacters. Staff retry a failed load with `/tcore packretry `. -- **Book appearance** — optionally hides the default glint on signed books through ProtocolLib. +- **Book appearance** — optionally hides the default glint on signed books by storing a glint override on the books (`hide-book-glint`). - **Book copies** — written books can be copied with a Book and Quill at a crafting table, even though MMOItems blocks vanilla recipes that use a disable-crafting MMOItem (`allow-book-copies`). - **Cross-plugin statistics** — records supported vehicle, character, crafting, skill, and faction events for staff queries. diff --git a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java index 64468fd..359f677 100644 --- a/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java +++ b/src/main/java/net/tfminecraft/tfmccore/TFMCCore.java @@ -15,6 +15,7 @@ import net.tfminecraft.tlibs.database.SqliteProvider; import net.tfminecraft.tfmccore.books.BookCopyUnblocker; import net.tfminecraft.tfmccore.books.BookGlintHider; +import net.tfminecraft.tfmccore.books.BookGlintStamper; import net.tfminecraft.tfmccore.commands.CoreCommands; import net.tfminecraft.tfmccore.commands.CoreTabCompletion; import net.tfminecraft.tfmccore.commands.SilentPermissionCommand; @@ -331,6 +332,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(new XaeroFairPlayListener(this), this); // Covers a plugin reload; on a normal start nobody is online yet getServer().getOnlinePlayers().forEach(XaeroFairPlayListener::send); + BookGlintStamper.register(this); BookGlintHider.register(this); bookCopies = BookCopyUnblocker.register(this); } diff --git a/src/main/java/net/tfminecraft/tfmccore/books/BookGlintHider.java b/src/main/java/net/tfminecraft/tfmccore/books/BookGlintHider.java index 808ce2e..b1256ed 100644 --- a/src/main/java/net/tfminecraft/tfmccore/books/BookGlintHider.java +++ b/src/main/java/net/tfminecraft/tfmccore/books/BookGlintHider.java @@ -23,21 +23,18 @@ import net.tfminecraft.tfmccore.cache.Cache; /** - * Hides the enchantment glint signed books have by default. Only the item packets - * sent to players change; the books stored on the server keep their data. + * Hides the enchantment glint on signed books shown on entities (dropped items, item frames, + * displays and held or worn books) that {@link BookGlintStamper} has not reached. Only entity + * packets change: clients hash the stacks they click in inventories, so an inventory item + * changed in packets would never match the server's copy. */ public final class BookGlintHider extends PacketAdapter { BookGlintHider(JavaPlugin plugin) { // HIGHEST: run after plugins that rewrite items so their copy is the one changed super(plugin, ListenerPriority.HIGHEST, - PacketType.Play.Server.SET_SLOT, - PacketType.Play.Server.WINDOW_ITEMS, - PacketType.Play.Server.SET_CURSOR_ITEM, - PacketType.Play.Server.SET_PLAYER_INVENTORY, PacketType.Play.Server.ENTITY_EQUIPMENT, - PacketType.Play.Server.ENTITY_METADATA, - PacketType.Play.Client.SET_CREATIVE_SLOT); + PacketType.Play.Server.ENTITY_METADATA); } public static void register(JavaPlugin plugin) { @@ -53,21 +50,7 @@ public void onPacketSending(PacketEvent event) { if (!Cache.hideBookGlint) return; PacketContainer packet = event.getPacket(); PacketType type = event.getPacketType(); - if (type == PacketType.Play.Server.WINDOW_ITEMS) { - StructureModifier> lists = packet.getItemListModifier(); - List items = new ArrayList<>(lists.read(0)); - boolean changed = false; - for (int i = 0; i < items.size(); i++) { - ItemStack hidden = hide(items.get(i)); - if (hidden != null) { - items.set(i, hidden); - changed = true; - } - } - if (changed) lists.write(0, items); - // The item held on the cursor - hideItem(packet); - } else if (type == PacketType.Play.Server.ENTITY_EQUIPMENT) { + if (type == PacketType.Play.Server.ENTITY_EQUIPMENT) { StructureModifier>> slots = packet.getSlotStackPairLists(); List> pairs = slots.read(0); boolean changed = false; @@ -79,8 +62,8 @@ public void onPacketSending(PacketEvent event) { } } if (changed) slots.write(0, pairs); - } else if (type == PacketType.Play.Server.ENTITY_METADATA) { - // Dropped items, item frames and item displays + } else { + // ENTITY_METADATA: dropped items, item frames and item displays StructureModifier> data = packet.getDataValueCollectionModifier(); List values = new ArrayList<>(data.read(0)); boolean changed = false; @@ -95,26 +78,9 @@ public void onPacketSending(PacketEvent event) { } } if (changed) data.write(0, values); - } else { - hideItem(packet); } } - @Override - public void onPacketReceiving(PacketEvent event) { - // Creative clients send back the stack they were shown. Drop the hidden flag so it - // is never saved, even after the option is turned off. - StructureModifier items = event.getPacket().getItemModifier(); - ItemStack restored = restore(items.read(0)); - if (restored != null) items.write(0, restored); - } - - private static void hideItem(PacketContainer packet) { - StructureModifier items = packet.getItemModifier(); - ItemStack hidden = hide(items.read(0)); - if (hidden != null) items.write(0, hidden); - } - /** A copy of a signed book with its glint turned off, or null when nothing changes. */ static ItemStack hide(ItemStack item) { if (item == null || item.getType() != Material.WRITTEN_BOOK) return null; @@ -128,15 +94,4 @@ static ItemStack hide(ItemStack item) { copy.setItemMeta(meta); return copy; } - - /** A copy of a signed book without the hidden-glint flag, or null when nothing changes. */ - static ItemStack restore(ItemStack item) { - if (item == null || item.getType() != Material.WRITTEN_BOOK) return null; - ItemMeta meta = item.getItemMeta(); - if (meta == null || !meta.hasEnchantmentGlintOverride() || meta.getEnchantmentGlintOverride()) return null; - meta.setEnchantmentGlintOverride(null); - ItemStack copy = item.clone(); - copy.setItemMeta(meta); - return copy; - } } diff --git a/src/main/java/net/tfminecraft/tfmccore/books/BookGlintStamper.java b/src/main/java/net/tfminecraft/tfmccore/books/BookGlintStamper.java new file mode 100644 index 0000000..69936f6 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmccore/books/BookGlintStamper.java @@ -0,0 +1,133 @@ +package net.tfminecraft.tfmccore.books; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.DoubleChest; +import org.bukkit.block.Lectern; +import org.bukkit.entity.Entity; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.BlockInventoryHolder; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; + +import net.tfminecraft.tfmccore.cache.Cache; + +/** + * Hides the enchantment glint signed books have by default by storing the override on the + * books themselves. Clients report a hash of every stack they click; a glint changed only in + * packets never matches the server's book, and the resync that follows can arrive after the + * next click and leave a ghost copy on the cursor. Books this sets are tagged, so turning the + * option off puts the glint back on only those. + */ +public final class BookGlintStamper implements Listener { + + static final long SWEEP_TICKS = 20; + /** Book handlers compare the book with their own copy a tick later; leave it alone meanwhile. */ + static final int QUIET_TICKS = 5; + + static final NamespacedKey KEY = new NamespacedKey("tfmccore", "hidden_glint"); + + private final Map quietUntil = new HashMap<>(); + + public static BookGlintStamper register(JavaPlugin plugin) { + BookGlintStamper stamper = new BookGlintStamper(); + plugin.getServer().getPluginManager().registerEvents(stamper, plugin); + plugin.getServer().getScheduler().runTaskTimer(plugin, stamper::sweep, SWEEP_TICKS, SWEEP_TICKS); + return stamper; + } + + /** Brings online players' own inventories in line with the option. */ + void sweep() { + int now = Bukkit.getCurrentTick(); + quietUntil.values().removeIf(until -> until <= now); + for (Player player : Bukkit.getOnlinePlayers()) { + if (!quietUntil.containsKey(player.getUniqueId())) update(player.getInventory()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onOpen(InventoryOpenEvent event) { + Inventory top = event.getView().getTopInventory(); + if (isStorage(top)) update(top); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEdit(PlayerEditBookEvent event) { + quiet(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onInteract(PlayerInteractEvent event) { + ItemStack item = event.getItem(); + if (item != null && item.getType() == Material.WRITTEN_BOOK) quiet(event.getPlayer()); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + quietUntil.remove(event.getPlayer().getUniqueId()); + } + + private void quiet(Player player) { + quietUntil.put(player.getUniqueId(), Bukkit.getCurrentTick() + QUIET_TICKS); + } + + /** Containers in the world. Plugin menus may match their own icons exactly, so skip them. */ + static boolean isStorage(Inventory inventory) { + InventoryHolder holder = inventory.getHolder(false); + // A lectern resets to the first page when its book is set + if (holder instanceof Lectern) return false; + // Menus are often opened with the player as holder; of a player's own, only the ender chest + if (holder instanceof HumanEntity human) return inventory.equals(human.getEnderChest()); + return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest || holder instanceof Entity; + } + + void update(Inventory inventory) { + ItemStack[] contents = inventory.getContents(); + for (int slot = 0; slot < contents.length; slot++) { + ItemStack updated = updated(contents[slot]); + if (updated != null) inventory.setItem(slot, updated); + } + } + + /** A copy of a signed book matching the option, or null when nothing changes. */ + ItemStack updated(ItemStack item) { + if (item == null || item.getType() != Material.WRITTEN_BOOK) return null; + ItemMeta meta = item.getItemMeta(); + if (meta == null) return null; + PersistentDataContainer data = meta.getPersistentDataContainer(); + if (data.has(KEY, PersistentDataType.BYTE)) { + if (Cache.hideBookGlint) return null; + data.remove(KEY); + if (meta.hasEnchantmentGlintOverride() && !meta.getEnchantmentGlintOverride()) { + meta.setEnchantmentGlintOverride(null); + } + } else { + // Glint true is the written book default and is never stored, so only a book + // that already turned it off has an override + if (!Cache.hideBookGlint || meta.hasEnchantmentGlintOverride()) return null; + meta.setEnchantmentGlintOverride(false); + data.set(KEY, PersistentDataType.BYTE, (byte) 1); + } + ItemStack copy = item.clone(); + copy.setItemMeta(meta); + return copy; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 19dcc6f..e411e68 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -24,8 +24,10 @@ horse-archery: false prevent-golem-scrape: true # Send Xaero's Minimap fair-play code (no entity radar or cave mode) on every join. xaero-fair-play: true -# Hide the enchantment glint on signed books (letters and book skins too). Only changes -# what players see; needs ProtocolLib. After a reload, items update as they are resent. +# Hide the enchantment glint on signed books (letters and book skins too). Books are +# updated within a second in player inventories and when their container is opened. +# Updated books are tagged, so false brings back the glint on those the same way. +# ProtocolLib hides it on books not yet updated in item frames or on the ground. hide-book-glint: true # Let written books be copied with a Book and Quill at a crafting table. MMOItems # otherwise blocks the vanilla recipe, because every Book and Quill is a disable-crafting MMOItem. diff --git a/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java index 146be0f..ab36a94 100644 --- a/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/PluginLifecycleCoverageTest.java @@ -55,6 +55,7 @@ import net.tfminecraft.tfmccore.stats.categories.vehicles.VehiclesStatConfig; import net.tfminecraft.tfmccore.stones.LorestoneConfigLoader; import net.tfminecraft.tfmccore.books.BookCopyUnblocker; +import net.tfminecraft.tfmccore.books.BookGlintStamper; import net.tfminecraft.tfmccore.stones.StoneItems; import net.tfminecraft.tfmccore.stones.StoneListener; import net.tfminecraft.tfmccore.testsupport.TestRegistryAccess; @@ -294,8 +295,9 @@ void startupRegistersCommandsListenersAndTheActualLifecycleAndQuitCallbacks() th verify(rig.coreCommand).setTabCompleter(rig.tabCompletion); verify(rig.silentCommand).setExecutor(any(SilentPermissionCommand.class)); verify(rig.silentCommand).setTabCompleter(any(SilentPermissionCommand.class)); - assertEquals(10, rig.listeners.size()); + assertEquals(11, rig.listeners.size()); assertTrue(rig.listeners.stream().anyMatch(BookCopyUnblocker.class::isInstance)); + assertTrue(rig.listeners.stream().anyMatch(BookGlintStamper.class::isInstance)); assertTrue(rig.listeners.contains(rig.whistles.constructed().getFirst())); assertTrue(rig.listeners.contains(rig.stones.constructed().getFirst())); assertSame(rig.stones.constructed().getFirst(), TFMCCore.getStoneListener()); diff --git a/src/test/java/net/tfminecraft/tfmccore/books/BookGlintHiderTest.java b/src/test/java/net/tfminecraft/tfmccore/books/BookGlintHiderTest.java index 33c6ef7..4f9c73e 100644 --- a/src/test/java/net/tfminecraft/tfmccore/books/BookGlintHiderTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/books/BookGlintHiderTest.java @@ -16,6 +16,7 @@ import com.comphenix.protocol.wrappers.WrappedDataValue; import com.comphenix.protocol.wrappers.WrappedDataWatcher; import java.util.List; +import java.util.Set; import java.util.logging.Logger; import net.tfminecraft.tfmccore.cache.Cache; import org.bukkit.Bukkit; @@ -89,12 +90,11 @@ void registersOnlyWhenProtocolLibIsEnabled() { BookGlintHider.register(plugin); verify(manager).addPacketListener(any(BookGlintHider.class)); - assertTrue(hider.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.WINDOW_ITEMS)); - assertTrue( - hider - .getReceivingWhitelist() - .getTypes() - .contains(PacketType.Play.Client.SET_CREATIVE_SLOT)); + // Inventory packets carry stacks the client hashes back on click; never touch them + assertEquals( + Set.of(PacketType.Play.Server.ENTITY_EQUIPMENT, PacketType.Play.Server.ENTITY_METADATA), + hider.getSendingWhitelist().getTypes()); + assertTrue(hider.getReceivingWhitelist().getTypes().isEmpty()); } } @@ -118,79 +118,16 @@ void hideCopiesOnlySignedBooksThatStillGlint() { verify(book, never()).setItemMeta(any()); } - @Test - void restoreDropsOnlyAHiddenGlintFromSignedBooks() { - assertNull(BookGlintHider.restore(null)); - assertNull(BookGlintHider.restore(stack(Material.WRITABLE_BOOK, mock(ItemMeta.class)))); - assertNull(BookGlintHider.restore(stack(Material.WRITTEN_BOOK, null))); - assertNull(BookGlintHider.restore(stack(Material.WRITTEN_BOOK, mock(ItemMeta.class)))); - assertNull(BookGlintHider.restore(stack(Material.WRITTEN_BOOK, glint(true)))); - - ItemMeta meta = glint(false); - ItemStack book = stack(Material.WRITTEN_BOOK, meta); - ItemStack copy = book.clone(); - - assertSame(copy, BookGlintHider.restore(book)); - - verify(meta).setEnchantmentGlintOverride(null); - verify(copy).setItemMeta(meta); - verify(book, never()).setItemMeta(any()); - } - @Test void disabledOptionLeavesSentPacketsAlone() { Cache.hideBookGlint = false; - PacketEvent event = event(PacketType.Play.Server.SET_SLOT); + PacketEvent event = event(PacketType.Play.Server.ENTITY_EQUIPMENT); hider.onPacketSending(event); verify(event, never()).getPacket(); } - @Test - void singleItemPacketsSendTheHiddenCopy() { - PacketEvent event = event(PacketType.Play.Server.SET_SLOT); - StructureModifier items = items(event.getPacket(), book()); - - hider.onPacketSending(event); - - verify(items).write(eq(0), argThat(this::hidden)); - - PacketEvent other = event(PacketType.Play.Server.SET_CURSOR_ITEM); - StructureModifier unchanged = items(other.getPacket(), plain()); - - hider.onPacketSending(other); - - verify(unchanged, never()).write(anyInt(), any()); - } - - @Test - @SuppressWarnings("unchecked") - void windowItemsHideBooksInTheListAndOnTheCursor() { - PacketEvent event = event(PacketType.Play.Server.WINDOW_ITEMS); - PacketContainer packet = event.getPacket(); - ItemStack other = plain(); - StructureModifier> lists = mock(StructureModifier.class); - when(packet.getItemListModifier()).thenReturn(lists); - ItemStack book = book(); - when(lists.read(0)).thenReturn(List.of(book, other)); - StructureModifier cursor = items(packet, book()); - - hider.onPacketSending(event); - - verify(lists).write(eq(0), argThat(list -> hidden(list.get(0)) && list.get(1) == other)); - verify(cursor).write(eq(0), argThat(this::hidden)); - - when(lists.read(0)).thenReturn(List.of(other)); - when(cursor.read(0)).thenReturn(other); - clearInvocations(lists, cursor); - - hider.onPacketSending(event); - - verify(lists, never()).write(anyInt(), any()); - verify(cursor, never()).write(anyInt(), any()); - } - @Test @SuppressWarnings("unchecked") void equipmentHidesBooksHeldOrWornByEntities() { @@ -256,28 +193,6 @@ void metadataReplacesOnlyBookValues() { } } - @Test - void creativeSlotsSaveBooksWithoutTheHiddenGlint() { - Cache.hideBookGlint = false; - PacketEvent event = event(PacketType.Play.Client.SET_CREATIVE_SLOT); - ItemMeta meta = glint(false); - ItemStack book = stack(Material.WRITTEN_BOOK, meta); - StructureModifier items = items(event.getPacket(), book); - - hider.onPacketReceiving(event); - - verify(items).write(0, book.clone()); - verify(meta).setEnchantmentGlintOverride(null); - - ItemStack stone = plain(); - when(items.read(0)).thenReturn(stone); - clearInvocations(items); - - hider.onPacketReceiving(event); - - verify(items, never()).write(anyInt(), any()); - } - private boolean hidden(ItemStack item) { return item != null && item.getItemMeta() == HIDDEN; } @@ -309,13 +224,6 @@ private static ItemStack stack(Material type, ItemMeta meta) { return item; } - private static ItemMeta glint(boolean value) { - ItemMeta meta = mock(ItemMeta.class); - when(meta.hasEnchantmentGlintOverride()).thenReturn(true); - when(meta.getEnchantmentGlintOverride()).thenReturn(value); - return meta; - } - private static WrappedDataValue value( int index, WrappedDataWatcher.Serializer serializer, Object value) { WrappedDataValue wrapped = mock(WrappedDataValue.class); @@ -332,12 +240,4 @@ private static PacketEvent event(PacketType type) { when(event.getPacketType()).thenReturn(type); return event; } - - @SuppressWarnings("unchecked") - private static StructureModifier items(PacketContainer packet, ItemStack item) { - StructureModifier items = mock(StructureModifier.class); - when(packet.getItemModifier()).thenReturn(items); - when(items.read(0)).thenReturn(item); - return items; - } } diff --git a/src/test/java/net/tfminecraft/tfmccore/books/BookGlintStamperTest.java b/src/test/java/net/tfminecraft/tfmccore/books/BookGlintStamperTest.java new file mode 100644 index 0000000..a9094bd --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmccore/books/BookGlintStamperTest.java @@ -0,0 +1,291 @@ +package net.tfminecraft.tfmccore.books; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import net.tfminecraft.tfmccore.cache.Cache; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.block.Barrel; +import org.bukkit.block.DoubleChest; +import org.bukkit.block.Lectern; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.StorageMinecart; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +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.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class BookGlintStamperTest { + private static final NamespacedKey KEY = BookGlintStamper.KEY; + + private final BookGlintStamper stamper = new BookGlintStamper(); + private MockedStatic bukkit; + private boolean previous; + private int tick; + + @BeforeEach + void setUp() { + previous = Cache.hideBookGlint; + Cache.hideBookGlint = true; + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getCurrentTick).thenAnswer(call -> tick); + } + + @AfterEach + void restore() { + Cache.hideBookGlint = previous; + bukkit.close(); + } + + @Test + void registersTheListenerAndSweepsEverySecond() { + JavaPlugin plugin = mock(JavaPlugin.class); + Server server = mock(Server.class); + PluginManager plugins = mock(PluginManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(plugins); + when(server.getScheduler()).thenReturn(scheduler); + + BookGlintStamper registered = BookGlintStamper.register(plugin); + + verify(plugins).registerEvents(registered, plugin); + ArgumentCaptor sweep = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), sweep.capture(), eq(20L), eq(20L)); + Player player = player(); + online(player); + sweep.getValue().run(); + verify(player).getInventory(); + } + + @Test + void hidesTheGlintOfSignedBooksAndTagsThem() { + Book book = new Book(false, null); + + ItemStack updated = stamper.updated(book.item); + + assertSame(book.copy, updated); + verify(book.meta).setEnchantmentGlintOverride(false); + verify(book.data).set(KEY, PersistentDataType.BYTE, (byte) 1); + verify(book.copy).setItemMeta(book.meta); + verify(book.item, never()).setItemMeta(any()); + } + + @Test + void leavesOtherItemsAndBooksWithTheirOwnGlintAlone() { + assertNull(stamper.updated(null)); + ItemStack quill = mock(ItemStack.class); + when(quill.getType()).thenReturn(Material.WRITABLE_BOOK); + assertNull(stamper.updated(quill)); + ItemStack bare = mock(ItemStack.class); + when(bare.getType()).thenReturn(Material.WRITTEN_BOOK); + assertNull(stamper.updated(bare)); + assertNull(stamper.updated(new Book(false, true).item)); + assertNull(stamper.updated(new Book(false, false).item)); + assertNull(stamper.updated(new Book(true, false).item)); + } + + @Test + void turningTheOptionOffRestoresOnlyTaggedBooks() { + Cache.hideBookGlint = false; + assertNull(stamper.updated(new Book(false, null).item)); + assertNull(stamper.updated(new Book(false, false).item)); + + Book hidden = new Book(true, false); + assertSame(hidden.copy, stamper.updated(hidden.item)); + verify(hidden.data).remove(KEY); + verify(hidden.meta).setEnchantmentGlintOverride(null); + verify(hidden.copy).setItemMeta(hidden.meta); + + // Someone turned the glint back on since; keep their choice and drop only the tag + Book shown = new Book(true, true); + assertSame(shown.copy, stamper.updated(shown.item)); + verify(shown.data).remove(KEY); + verify(shown.meta, never()).setEnchantmentGlintOverride(any()); + + Book untouched = new Book(true, null); + assertSame(untouched.copy, stamper.updated(untouched.item)); + verify(untouched.meta, never()).setEnchantmentGlintOverride(any()); + } + + @Test + void inventoriesGetOnlyTheChangedSlotsBack() { + Inventory inventory = mock(Inventory.class); + Book book = new Book(false, null); + ItemStack stone = mock(ItemStack.class); + when(stone.getType()).thenReturn(Material.STONE); + when(inventory.getContents()).thenReturn(new ItemStack[] {stone, null, book.item}); + + stamper.update(inventory); + + verify(inventory).setItem(2, book.copy); + verify(inventory, times(1)).setItem(anyInt(), any()); + } + + @Test + void sweepsSkipPlayersWhoseBookIsBeingEdited() { + Player editing = player(); + Player reading = player(); + Player idle = player(); + online(editing, reading, idle); + stamper.onEdit(edit(editing)); + PlayerInteractEvent open = mock(PlayerInteractEvent.class); + when(open.getPlayer()).thenReturn(reading); + ItemStack book = new Book(true, false).item; + when(open.getItem()).thenReturn(book); + stamper.onInteract(open); + + tick = BookGlintStamper.QUIET_TICKS - 1; + stamper.sweep(); + + verify(editing, never()).getInventory(); + verify(reading, never()).getInventory(); + verify(idle).getInventory(); + + tick = BookGlintStamper.QUIET_TICKS; + stamper.sweep(); + + verify(editing).getInventory(); + verify(reading).getInventory(); + } + + @Test + void onlySignedBooksInHandQuietTheSweep() { + Player player = player(); + online(player); + PlayerInteractEvent empty = mock(PlayerInteractEvent.class); + stamper.onInteract(empty); + PlayerInteractEvent stone = mock(PlayerInteractEvent.class); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STONE); + when(stone.getItem()).thenReturn(item); + stamper.onInteract(stone); + + stamper.sweep(); + + verify(player).getInventory(); + verify(empty, never()).getPlayer(); + verify(stone, never()).getPlayer(); + } + + @Test + void quittingForgetsTheQuietWindow() { + Player player = player(); + online(player); + stamper.onEdit(edit(player)); + + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + stamper.onQuit(quit); + stamper.sweep(); + + verify(player).getInventory(); + } + + @Test + void openingAWorldContainerUpdatesItsBooks() { + Inventory barrel = storage(mock(Barrel.class)); + Book book = new Book(false, null); + when(barrel.getContents()).thenReturn(new ItemStack[] {book.item}); + + stamper.onOpen(open(barrel)); + + verify(barrel).setItem(0, book.copy); + + Inventory menu = storage(mock(InventoryHolder.class)); + stamper.onOpen(open(menu)); + verify(menu, never()).getContents(); + } + + @Test + void storageMeansContainersInTheWorldButNotLecterns() { + assertTrue(BookGlintStamper.isStorage(storage(mock(Barrel.class)))); + assertTrue(BookGlintStamper.isStorage(storage(mock(DoubleChest.class)))); + assertTrue(BookGlintStamper.isStorage(storage(mock(StorageMinecart.class)))); + Player player = mock(Player.class); + Inventory enderChest = storage(player); + when(player.getEnderChest()).thenReturn(enderChest); + assertTrue(BookGlintStamper.isStorage(enderChest)); + // A plugin menu opened with the player as holder + assertFalse(BookGlintStamper.isStorage(storage(player))); + assertFalse(BookGlintStamper.isStorage(storage(mock(InventoryHolder.class)))); + assertFalse(BookGlintStamper.isStorage(storage(null))); + assertFalse( + BookGlintStamper.isStorage(storage(mock(Lectern.class)))); + } + + private static PlayerEditBookEvent edit(Player player) { + PlayerEditBookEvent event = mock(PlayerEditBookEvent.class); + when(event.getPlayer()).thenReturn(player); + return event; + } + + private static InventoryOpenEvent open(Inventory top) { + InventoryOpenEvent event = mock(InventoryOpenEvent.class); + InventoryView view = mock(InventoryView.class); + when(event.getView()).thenReturn(view); + when(view.getTopInventory()).thenReturn(top); + return event; + } + + private static Inventory storage(InventoryHolder holder) { + Inventory inventory = mock(Inventory.class); + when(inventory.getHolder(false)).thenReturn(holder); + return inventory; + } + + private static Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + PlayerInventory inventory = mock(PlayerInventory.class); + when(inventory.getContents()).thenReturn(new ItemStack[0]); + when(player.getInventory()).thenReturn(inventory); + return player; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void online(Player... players) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn((Collection) List.of(players)); + } + + /** A signed book, optionally tagged by the stamper, with the given stored glint (null: none). */ + private static final class Book { + final ItemStack item = mock(ItemStack.class); + final ItemStack copy = mock(ItemStack.class); + final ItemMeta meta = mock(ItemMeta.class); + final PersistentDataContainer data = mock(PersistentDataContainer.class); + + Book(boolean tagged, Boolean glint) { + when(item.getType()).thenReturn(Material.WRITTEN_BOOK); + when(item.getItemMeta()).thenReturn(meta); + when(item.clone()).thenReturn(copy); + when(meta.getPersistentDataContainer()).thenReturn(data); + when(data.has(KEY, PersistentDataType.BYTE)).thenReturn(tagged); + when(meta.hasEnchantmentGlintOverride()).thenReturn(glint != null); + if (glint != null) when(meta.getEnchantmentGlintOverride()).thenReturn(glint); + } + } +}