Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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,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<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.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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}