diff --git a/src/main/java/net/tfminecraft/armourshop/ArmourShop.java b/src/main/java/net/tfminecraft/armourshop/ArmourShop.java index 9bbe01e..cf48685 100644 --- a/src/main/java/net/tfminecraft/armourshop/ArmourShop.java +++ b/src/main/java/net/tfminecraft/armourshop/ArmourShop.java @@ -13,6 +13,7 @@ import net.tfminecraft.armourshop.loaders.SkinSetLoader; import net.tfminecraft.armourshop.managers.CommandManager; import net.tfminecraft.armourshop.managers.BookSignSkinListener; +import net.tfminecraft.armourshop.managers.BookCommandSkinListener; import net.tfminecraft.armourshop.managers.SkinManager; import net.tfminecraft.armourshop.pack.reload.DeferredIaReloadService; import net.tfminecraft.armourshop.pack.apply.PackPullScheduler; @@ -81,6 +82,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(commandManager, this); getServer().getPluginManager().registerEvents(skinManager, this); getServer().getPluginManager().registerEvents(bookSignSkinListener, this); + getServer().getPluginManager().registerEvents(new BookCommandSkinListener(), this); getServer().getPluginManager().registerEvents(deferredIaReloadService, this); } public void loadConfigs() { diff --git a/src/main/java/net/tfminecraft/armourshop/managers/BookCommandSkinListener.java b/src/main/java/net/tfminecraft/armourshop/managers/BookCommandSkinListener.java new file mode 100644 index 0000000..577bd48 --- /dev/null +++ b/src/main/java/net/tfminecraft/armourshop/managers/BookCommandSkinListener.java @@ -0,0 +1,117 @@ +package net.tfminecraft.armourshop.managers; + +import java.util.Arrays; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.logging.Level; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.CommandException; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.inventory.meta.ItemMeta; + +import dev.lone.itemsadder.api.CustomStack; +import net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver; + +/** Let Essentials authorize /book, then retain the custom item during its conversion. */ +public final class BookCommandSkinListener implements Listener { + private final Function commands; + private final Predicate customBooks; + private final BiFunction restore; + + public BookCommandSkinListener() { + this(Bukkit::getPluginCommand, BookEditSkinPreserver::isCustomBook, + BookCommandSkinListener::restoreConversion); + } + + BookCommandSkinListener(Function commands, + Predicate customBooks, + BiFunction restore) { + this.commands = commands; + this.customBooks = customBooks; + this.restore = restore; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBookCommand(PlayerCommandPreprocessEvent event) { + if (event.isCancelled()) return; + String[] words = event.getMessage().substring(1).trim().split("\\s+"); + PluginCommand command = commands.apply(words[0]); + // Resolve aliases and namespaced commands, without intercepting other plugins' /book. + if (command == null || !command.getName().equalsIgnoreCase("book") + || !command.getPlugin().getName().equalsIgnoreCase("Essentials")) return; + ItemStack held = event.getPlayer().getInventory().getItemInMainHand(); + if (!isBook(held) || !customBooks.test(held)) return; + + ItemStack original = held.clone(); + event.setCancelled(true); + // Execute the original command synchronously: permissions, ownership, formatting and + // denial messages stay with Essentials. No deferred slot overwrite can move/duplicate books. + command.getPlugin().getLogger().info(event.getPlayer().getName() + + " issued server command: " + event.getMessage()); + try { + command.execute(event.getPlayer(), words[0], Arrays.copyOfRange(words, 1, words.length)); + } catch (CommandException failure) { + command.getPlugin().getLogger().log(Level.SEVERE, "Failed custom book command", failure); + event.getPlayer().sendMessage("An error occurred while editing your book. Please contact staff."); + } + ItemStack converted = event.getPlayer().getInventory().getItemInMainHand(); + if (!isBook(converted) || converted.getType() == original.getType()) return; + event.getPlayer().getInventory().setItemInMainHand(restore.apply(original, converted)); + } + + private static boolean isBook(ItemStack item) { + return item != null && (item.getType() == Material.WRITABLE_BOOK + || item.getType() == Material.WRITTEN_BOOK); + } + + @SuppressWarnings("deprecation") + static ItemStack restoreConversion(ItemStack original, ItemStack converted) { + BookMeta previous = (BookMeta) original.getItemMeta(); + BookMeta content = (BookMeta) converted.getItemMeta(); + ItemStack restored = original.clone(); + restored.setType(converted.getType()); + + CustomStack custom = CustomStack.byItemStack(original); + if (custom != null) { + String id = custom.getId(); + String targetId = converted.getType() == Material.WRITTEN_BOOK + ? (id.endsWith("_signed") ? id : id + "_signed") + : (id.endsWith("_signed") ? id.substring(0, id.length() - 7) : id); + CustomStack target = CustomStack.getInstance(custom.getNamespace() + ":" + targetId); + if (target != null && target.getItemStack().getType() == converted.getType()) { + ItemMeta appearance = target.getItemStack().getItemMeta(); + ItemMeta meta = restored.getItemMeta(); + meta.setCustomModelDataComponent(appearance.getCustomModelDataComponent()); + meta.setItemModel(appearance.getItemModel()); + restored.setItemMeta(meta); + restored = ItemSkinPreserver.writeIaTag(restored, custom.getNamespace(), targetId); + ItemSkinPreserver.writeItemsAdderCompound(restored, custom.getNamespace(), targetId); + if (appearance.hasCustomModelData()) { + restored = ItemSkinPreserver.writeAmodel(restored, appearance.getCustomModelData()); + } + } + } + + // setType clears book content. Use the original pages, avoiding Essentials' legacy + // plain-string round trip, while retaining every other original item component. + BookMeta meta = (BookMeta) restored.getItemMeta(); + meta.spigot().setPages(previous.spigot().getPages()); + if (converted.getType() == Material.WRITTEN_BOOK) { + meta.setTitle(content.hasTitle() ? content.getTitle() : "Book"); + meta.setAuthor(content.getAuthor()); + if (content.hasGeneration()) meta.setGeneration(content.getGeneration()); + } + restored.setItemMeta(meta); + restored.setAmount(converted.getAmount()); + return restored; + } +} diff --git a/src/test/java/net/tfminecraft/armourshop/managers/BookCommandSkinListenerTest.java b/src/test/java/net/tfminecraft/armourshop/managers/BookCommandSkinListenerTest.java new file mode 100644 index 0000000..f5edeef --- /dev/null +++ b/src/test/java/net/tfminecraft/armourshop/managers/BookCommandSkinListenerTest.java @@ -0,0 +1,104 @@ +package net.tfminecraft.armourshop.managers; + +import org.bukkit.Material; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class BookCommandSkinListenerTest { + private final Player player = mock(Player.class); + private final PlayerInventory inventory = mock(PlayerInventory.class); + private final PluginCommand command = mock(PluginCommand.class); + private final Plugin essentials = mock(Plugin.class); + private final ItemStack held = mock(ItemStack.class); + private final ItemStack original = mock(ItemStack.class); + private final ItemStack converted = mock(ItemStack.class); + private final ItemStack restored = mock(ItemStack.class); + + @BeforeEach void setup() { + when(player.getServer()).thenReturn(mock(org.bukkit.Server.class)); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItemInMainHand()).thenReturn(held, converted); + when(held.getType()).thenReturn(Material.WRITTEN_BOOK); + when(held.clone()).thenReturn(original); + when(original.getType()).thenReturn(Material.WRITTEN_BOOK); + when(converted.getType()).thenReturn(Material.WRITABLE_BOOK); + when(command.getName()).thenReturn("book"); + when(command.getPlugin()).thenReturn(essentials); + when(essentials.getName()).thenReturn("Essentials"); + when(essentials.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + } + + @Test void delegatesNamespacedCommandAndRepairsImmediately() { + var event = new PlayerCommandPreprocessEvent(player, "/essentials:book unsign"); + var listener = new BookCommandSkinListener(label -> command, item -> true, + (before, after) -> { + org.junit.jupiter.api.Assertions.assertSame(original, before); + org.junit.jupiter.api.Assertions.assertSame(converted, after); + return restored; + }); + listener.onBookCommand(event); + org.junit.jupiter.api.Assertions.assertTrue(event.isCancelled()); + var order = inOrder(command, inventory); + order.verify(command).execute(eq(player), eq("essentials:book"), aryEq("unsign")); + order.verify(inventory).setItemInMainHand(restored); + } + + @Test void deniedConversionNeverReplacesBook() { + when(converted.getType()).thenReturn(Material.WRITTEN_BOOK); + new BookCommandSkinListener(label -> command, item -> true, (a, b) -> { + throw new AssertionError("denied commands must not restore"); + }).onBookCommand(new PlayerCommandPreprocessEvent(player, "/book unsign")); + verify(command).execute(eq(player), eq("book"), aryEq("unsign")); + verify(inventory, never()).setItemInMainHand(any()); + } + + @Test void titleAndAuthorEditsStayWithEssentials() { + when(converted.getType()).thenReturn(Material.WRITTEN_BOOK); + new BookCommandSkinListener(label -> command, item -> true, (a, b) -> restored) + .onBookCommand(new PlayerCommandPreprocessEvent(player, "/book title My Notebook")); + verify(command).execute(eq(player), eq("book"), aryEq("title", "My", "Notebook")); + verify(inventory, never()).setItemInMainHand(any()); + } + + @Test void vanillaBooksAndOtherPluginsAreNotIntercepted() { + var event = new PlayerCommandPreprocessEvent(player, "/book"); + new BookCommandSkinListener(label -> command, item -> false, (a, b) -> restored) + .onBookCommand(event); + org.junit.jupiter.api.Assertions.assertFalse(event.isCancelled()); + when(essentials.getName()).thenReturn("AnotherPlugin"); + new BookCommandSkinListener(label -> command, item -> true, (a, b) -> restored) + .onBookCommand(event); + verify(command, never()).execute(any(), any(), any()); + } + + @Test void cancelledCommandsAreNotExecuted() { + var event = new PlayerCommandPreprocessEvent(player, "/book"); + event.setCancelled(true); + new BookCommandSkinListener(label -> command, item -> true, (a, b) -> restored) + .onBookCommand(event); + verifyNoInteractions(command); + } + + @Test void executionFailureReportsErrorAndPreservesPartialConversion() { + when(command.execute(any(), any(), any())) + .thenThrow(new org.bukkit.command.CommandException("test failure")); + new BookCommandSkinListener(label -> command, item -> true, (a, b) -> restored) + .onBookCommand(new PlayerCommandPreprocessEvent(player, "/book unsign")); + verify(player).sendMessage(contains("error occurred")); + verify(inventory).setItemInMainHand(restored); + verify(essentials.getLogger()).log(eq(java.util.logging.Level.SEVERE), + eq("Failed custom book command"), any(Throwable.class)); + } + + private static String[] aryEq(String... values) { + return org.mockito.AdditionalMatchers.aryEq(values); + } +}