Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/net/tfminecraft/armourshop/ArmourShop.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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 org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.PluginCommand;
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<String, PluginCommand> commands;
private final Predicate<ItemStack> customBooks;
private final BiFunction<ItemStack, ItemStack, ItemStack> restore;

public BookCommandSkinListener() {
this(Bukkit::getPluginCommand, BookEditSkinPreserver::isCustomBook,
BookCommandSkinListener::restoreConversion);
}

BookCommandSkinListener(Function<String, PluginCommand> commands,
Predicate<ItemStack> customBooks,
BiFunction<ItemStack, ItemStack, ItemStack> 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.execute(event.getPlayer(), words[0], Arrays.copyOfRange(words, 1, words.length));
ItemStack converted = event.getPlayer().getInventory().getItemInMainHand();
if (!isBook(converted) || converted.getType() == original.getType()) return;
event.getPlayer().getInventory().setItemInMainHand(restore.apply(original, converted));
Comment thread
XxFran10xX marked this conversation as resolved.
Outdated
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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");
}

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

private static String[] aryEq(String... values) {
return org.mockito.AdditionalMatchers.aryEq(values);
}
}