diff --git a/src/main/java/dev/noah/perplayerkit/KitRoomDataManager.java b/src/main/java/dev/noah/perplayerkit/KitRoomDataManager.java index ae526c2..0971896 100644 --- a/src/main/java/dev/noah/perplayerkit/KitRoomDataManager.java +++ b/src/main/java/dev/noah/perplayerkit/KitRoomDataManager.java @@ -74,6 +74,10 @@ public ItemStack[] getKitRoomPage(int page) { return kitroomData.get(page); } + public int pageCount() { + return kitroomData.size(); + } + public void saveToDBAsync() { new BukkitRunnable() { diff --git a/src/main/java/dev/noah/perplayerkit/PerPlayerKit.java b/src/main/java/dev/noah/perplayerkit/PerPlayerKit.java index 9e18c6b..f075a3c 100644 --- a/src/main/java/dev/noah/perplayerkit/PerPlayerKit.java +++ b/src/main/java/dev/noah/perplayerkit/PerPlayerKit.java @@ -47,6 +47,7 @@ import dev.noah.perplayerkit.listeners.antiexploit.CommandListener; import dev.noah.perplayerkit.listeners.antiexploit.ShulkerDropItemsListener; import dev.noah.perplayerkit.listeners.features.OldDeathDropListener; +import dev.noah.perplayerkit.gui.configurable.ConfigurableGuiService; import dev.noah.perplayerkit.storage.StorageManager; import dev.noah.perplayerkit.storage.StorageSelector; import dev.noah.perplayerkit.storage.exceptions.StorageConnectionException; @@ -95,6 +96,7 @@ public void onEnable() { new Lang(this); new StyleManager(this); + new ConfigurableGuiService(this); new ItemFilter(this); new BroadcastManager(this); diff --git a/src/main/java/dev/noah/perplayerkit/commands/kits/EnderchestCommand.java b/src/main/java/dev/noah/perplayerkit/commands/kits/EnderchestCommand.java index 1091bf9..75487d1 100644 --- a/src/main/java/dev/noah/perplayerkit/commands/kits/EnderchestCommand.java +++ b/src/main/java/dev/noah/perplayerkit/commands/kits/EnderchestCommand.java @@ -19,17 +19,11 @@ package dev.noah.perplayerkit.commands.kits; import dev.noah.perplayerkit.commands.core.CommandGuards; -import dev.noah.perplayerkit.gui.ItemUtil; -import dev.noah.perplayerkit.util.Lang; -import dev.noah.perplayerkit.util.StyleManager; -import dev.noah.perplayerkit.util.SoundManager; +import dev.noah.perplayerkit.gui.configurable.ConfigurableGuiService; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; -import org.ipvp.canvas.Menu; -import org.ipvp.canvas.type.ChestMenu; import org.jetbrains.annotations.NotNull; public class EnderchestCommand implements CommandExecutor { @@ -45,24 +39,6 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command } public void viewOnlyEC(Player player) { - - ItemStack fill = ItemUtil.createGlassPane(); - - Menu menu = ChestMenu.builder(5).title(StyleManager.get().getPrimaryColor() + Lang.get().legacy("gui.enderchest-view-title")).build(); - - - for (int i = 0; i < 9; i++) { - menu.getSlot(i).setItem(fill); - } - for (int i = 36; i < 45; i++) { - menu.getSlot(i).setItem(fill); - } -// set the items in the inventory to the items in the enderchest - ItemStack[] items = player.getEnderChest().getContents(); - for (int i = 0; i < 27; i++) { - menu.getSlot(i + 9).setItem(items[i]); - } - menu.open(player); - SoundManager.playOpenGui(player); + ConfigurableGuiService.get().openViewOnlyEnderchest(player); } } diff --git a/src/main/java/dev/noah/perplayerkit/gui/EditorSaver.java b/src/main/java/dev/noah/perplayerkit/gui/EditorSaver.java deleted file mode 100644 index cdcbe92..0000000 --- a/src/main/java/dev/noah/perplayerkit/gui/EditorSaver.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2022-2025 Noah Ross - * - * This file is part of PerPlayerKit. - * - * PerPlayerKit is free software: you can redistribute it and/or modify it under - * the terms of the GNU Affero General Public License as published by the - * Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for - * more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with PerPlayerKit. If not, see . - */ -package dev.noah.perplayerkit.gui; - -import dev.noah.perplayerkit.KitManager; -import dev.noah.perplayerkit.util.Lang; -import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; - -import java.util.UUID; - -/** - * Persists the contents of a kit/enderchest editor menu. Invoked both when the - * inventory actually closes (KitMenuCloseListener) and when the player - * navigates to another menu that reuses the open inventory, where no - * InventoryCloseEvent ever fires (GUI redraw navigation). - */ -public final class EditorSaver { - - private EditorSaver() { - } - - public static void save(Player player, GUI.EditorContext context, Inventory inv) { - switch (context.type()) { - case KIT -> saveKit(player, context.slot(), inv); - case PUBLIC_KIT -> savePublicKit(player, context.id(), inv); - case ENDERCHEST -> saveEnderchest(player, context.slot(), inv); - case INSPECT_KIT -> saveInspectedKit(player, context, inv); - case INSPECT_ENDERCHEST -> saveInspectedEnderchest(player, context, inv); - } - } - - private static void saveKit(Player player, int slot, Inventory inv) { - ItemStack[] kit = copyContents(inv.getContents(), 41); - KitManager.get().savekit(player.getUniqueId(), slot, kit); - } - - private static void savePublicKit(Player player, String id, Inventory inv) { - if (id == null || id.isEmpty()) { - return; - } - ItemStack[] kit = copyContents(inv.getContents(), 41); - KitManager.get().savePublicKit(player, id, kit); - } - - private static void saveEnderchest(Player player, int slot, Inventory inv) { - ItemStack[] ec = copyContentsFromOffset(inv.getContents(), 9, 27); - KitManager.get().saveEC(player.getUniqueId(), slot, ec); - } - - private static void saveInspectedKit(Player player, GUI.EditorContext context, Inventory inv) { - if (!player.hasPermission("perplayerkit.admin") || context.target() == null || GUI.removeKitDeletionFlag(player)) { - return; - } - UUID targetUuid = context.target(); - int slot = context.slot(); - String playerName = context.playerName(); - ItemStack[] kit = copyContents(inv.getContents(), 41); - if (KitManager.get().savekit(targetUuid, slot, kit, true)) { - Lang.get().send(player, "success.admin-kit-updated", "slot", String.valueOf(slot), "player", playerName); - } else { - Lang.get().send(player, "error.failed-to-update-kit", "player", playerName); - } - } - - private static void saveInspectedEnderchest(Player player, GUI.EditorContext context, Inventory inv) { - if (!player.hasPermission("perplayerkit.admin") || context.target() == null || GUI.removeKitDeletionFlag(player)) { - return; - } - UUID targetUuid = context.target(); - int slot = context.slot(); - String playerName = context.playerName(); - ItemStack[] ec = copyContentsFromOffset(inv.getContents(), 9, 27); - if (KitManager.get().saveECSilent(targetUuid, slot, ec)) { - Lang.get().send(player, "success.admin-ec-updated", "slot", String.valueOf(slot), "player", playerName); - } else { - Lang.get().send(player, "error.failed-to-update-ec", "player", playerName); - } - } - - private static ItemStack[] copyContents(ItemStack[] source, int count) { - ItemStack[] out = new ItemStack[count]; - for (int i = 0; i < count; i++) { - out[i] = source[i] == null ? null : source[i].clone(); - } - return out; - } - - private static ItemStack[] copyContentsFromOffset(ItemStack[] source, int offset, int count) { - ItemStack[] out = new ItemStack[count]; - for (int i = 0; i < count; i++) { - out[i] = source[i + offset] == null ? null : source[i + offset].clone(); - } - return out; - } -} diff --git a/src/main/java/dev/noah/perplayerkit/gui/GUI.java b/src/main/java/dev/noah/perplayerkit/gui/GUI.java index 1a7a6e3..5ef6c01 100644 --- a/src/main/java/dev/noah/perplayerkit/gui/GUI.java +++ b/src/main/java/dev/noah/perplayerkit/gui/GUI.java @@ -18,708 +18,39 @@ */ package dev.noah.perplayerkit.gui; -import com.google.common.primitives.Ints; -import dev.noah.perplayerkit.ItemFilter; -import dev.noah.perplayerkit.KitManager; -import dev.noah.perplayerkit.KitRoomDataManager; -import dev.noah.perplayerkit.PublicKit; -import dev.noah.perplayerkit.util.*; -import net.md_5.bungee.api.ChatColor; -import org.bukkit.Material; +import dev.noah.perplayerkit.gui.configurable.ConfigurableGuiService; import org.bukkit.entity.Player; -import org.bukkit.event.inventory.ClickType; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; -import org.ipvp.canvas.Menu; -import org.ipvp.canvas.slot.Slot; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import static dev.noah.perplayerkit.gui.ItemUtil.addHideFlags; -import static dev.noah.perplayerkit.gui.ItemUtil.createItem; -import static dev.noah.perplayerkit.gui.ItemUtil.createGlassPane; -import static dev.noah.perplayerkit.gui.GuiLayoutUtils.*; -import static dev.noah.perplayerkit.util.PlayerUtil.getPlayerName; - +/** + * Facade over {@link ConfigurableGuiService}, keeping the entry points (and + * their historical names) that commands and listeners call. Menu layout and + * behavior live in guis.yml and the service. + */ public class GUI { - private final Plugin plugin; - private final boolean filterItemsOnImport; - private static final Set kitDeletionFlag = new HashSet<>(); - private static final Map editorContexts = new HashMap<>(); - // Last main-menu page each player viewed, so back buttons from submenus - // (kit room, public kits) return to it instead of resetting to page 1. - private static final Map lastMainMenuPage = new HashMap<>(); - - public enum EditorType { - KIT, - PUBLIC_KIT, - ENDERCHEST, - INSPECT_KIT, - INSPECT_ENDERCHEST - } - - public record EditorContext(EditorType type, int slot, String id, UUID target, String playerName) { - } - - public static EditorContext getAndRemoveEditorContext(UUID viewer) { - return editorContexts.remove(viewer); - } - - public static void forgetMainMenuPage(UUID player) { - lastMainMenuPage.remove(player); - } - - private static void setEditorContext(Player viewer, EditorContext context) { - editorContexts.put(viewer.getUniqueId(), context); - } - - // Menus open through here so navigation between them works with canvas - // redraw: any pending editor save is flushed first (a redraw reuses the - // open inventory, so InventoryCloseEvent never fires for the editor), and - // the stale title left behind by the reuse is updated in place. - private void openMenu(Player p, GuiMenuFactory.TitledMenu titledMenu) { - flushOpenEditor(p); - titledMenu.menu().open(p); - GuiCompat.updateTitle(p, titledMenu.title()); - } - - private static void flushOpenEditor(Player p) { - EditorContext context = editorContexts.remove(p.getUniqueId()); - if (context == null) { - return; - } - Inventory top = p.getOpenInventory().getTopInventory(); - if (top.getSize() == MENU_SIZE && top.getLocation() == null) { - EditorSaver.save(p, context, top); - } - } public GUI(Plugin plugin) { - this.plugin = plugin; - this.filterItemsOnImport = plugin.getConfig().getBoolean("anti-exploit.import-filter", false); - } - - public static void addLoadPublicKit(Slot slot, String id) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - KitManager.get().loadPublicKit(player, id); - info.getClickedMenu().close(); - }); - } - - public static boolean removeKitDeletionFlag(Player player) { - return kitDeletionFlag.remove(player.getUniqueId()); - } - - private static String lang(String key) { - return Lang.get().raw(key); - } - - private static String lang(String key, String... pairs) { - return Lang.get().raw(key, pairs); - } - - public void OpenKitMenu(Player p, int slot) { - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createKitMenu(slot); - Menu menu = titledMenu.menu(); - - if (KitManager.get().getItemStackArrayById(p.getUniqueId().toString() + slot) != null) { - ItemStack[] kit = KitManager.get().getItemStackArrayById(p.getUniqueId().toString() + slot); - for (int i = 0; i < KIT_CONTENT_END; i++) { - menu.getSlot(i).setItem(kit[i]); - } - } - allowModificationRange(menu, 0, KIT_CONTENT_END); - setGlassPaneRange(menu, KIT_CONTENT_END, MENU_SIZE); - setArmorAndOffhandIndicators(menu); - - menu.getSlot(IMPORT_SLOT).setItem(createItem(Material.CHEST, 1, lang("gui.import-button"), lang("gui.lore-import-inventory"))); - menu.getSlot(CLEAR_SLOT).setItem(createItem(Material.BARRIER, 1, lang("gui.clear-kit-button"), lang("gui.lore-shift-clear"))); - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - addMainButton(menu.getSlot(BACK_SLOT), KitSlots.pageOf(slot)); - addClear(menu.getSlot(CLEAR_SLOT)); - addImport(menu.getSlot(IMPORT_SLOT)); - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - - openMenu(p, titledMenu); - setEditorContext(p, new EditorContext(EditorType.KIT, slot, null, null, null)); - } - - public void OpenPublicKitEditor(Player p, String kitId) { - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createPublicKitMenu(kitId); - Menu menu = titledMenu.menu(); - - if (KitManager.get().getItemStackArrayById(IDUtil.getPublicKitId(kitId)) != null) { - ItemStack[] kit = KitManager.get().getItemStackArrayById(IDUtil.getPublicKitId(kitId)); - for (int i = 0; i < KIT_CONTENT_END; i++) { - menu.getSlot(i).setItem(kit[i]); - } - } - allowModificationRange(menu, 0, KIT_CONTENT_END); - setGlassPaneRange(menu, KIT_CONTENT_END, MENU_SIZE); - setArmorAndOffhandIndicators(menu); - - menu.getSlot(IMPORT_SLOT).setItem(createItem(Material.CHEST, 1, lang("gui.import-button"), lang("gui.lore-import-inventory"))); - menu.getSlot(CLEAR_SLOT).setItem(createItem(Material.BARRIER, 1, lang("gui.clear-kit-button"), lang("gui.lore-shift-clear"))); - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - addMainButton(menu.getSlot(BACK_SLOT)); - addClear(menu.getSlot(CLEAR_SLOT)); - addImport(menu.getSlot(IMPORT_SLOT)); - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - - openMenu(p, titledMenu); - setEditorContext(p, new EditorContext(EditorType.PUBLIC_KIT, 0, kitId, null, null)); - } - - public void OpenECKitKenu(Player p, int slot) { - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createECMenu(slot); - Menu menu = titledMenu.menu(); - - setGlassPaneRange(menu, 0, EC_CONTENT_START); - setGlassPaneRange(menu, EC_CONTENT_END, MENU_SIZE); - if (KitManager.get().getItemStackArrayById(p.getUniqueId() + "ec" + slot) != null) { - - ItemStack[] kit = KitManager.get().getItemStackArrayById(p.getUniqueId() + "ec" + slot); - for (int i = EC_CONTENT_START; i < EC_CONTENT_END; i++) { - menu.getSlot(i).setItem(kit[i - EC_CONTENT_START]); - } - } - allowModificationRange(menu, EC_CONTENT_START, EC_CONTENT_END); - menu.getSlot(IMPORT_SLOT).setItem(createItem(Material.ENDER_CHEST, 1, lang("gui.import-button"), lang("gui.lore-import-ec"))); - menu.getSlot(CLEAR_SLOT).setItem(createItem(Material.BARRIER, 1, lang("gui.clear-kit-button"), lang("gui.lore-shift-clear"))); - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - addMainButton(menu.getSlot(BACK_SLOT), KitSlots.pageOf(slot)); - addClear(menu.getSlot(CLEAR_SLOT), EC_CONTENT_START, EC_CONTENT_END); - addImportEC(menu.getSlot(IMPORT_SLOT)); - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - openMenu(p, titledMenu); - setEditorContext(p, new EditorContext(EditorType.ENDERCHEST, slot, null, null, null)); - } - - public void InspectKit(Player p, UUID target, int slot) { - String playerName = getPlayerName(target); - if (playerName == null) { - playerName = target.toString(); - } - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createInspectMenu(slot, playerName); - Menu menu = titledMenu.menu(); - - if (KitManager.get().hasKit(target, slot)) { - ItemStack[] kit = KitManager.get().getItemStackArrayById(target.toString() + slot); - for (int i = 0; i < KIT_CONTENT_END; i++) { - menu.getSlot(i).setItem(kit[i]); - } - } - setGlassPaneRange(menu, KIT_CONTENT_END, MENU_SIZE); - setArmorAndOffhandIndicators(menu); - - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.close-button"))); - menu.getSlot(BACK_SLOT).setClickHandler((player, info) -> { - SoundManager.playClick(player); - info.getClickedMenu().close(); - SoundManager.playCloseGui(player); - }); - - if (p.hasPermission("perplayerkit.admin")) { - allowModificationRange(menu, 0, KIT_CONTENT_END); - menu.getSlot(CLEAR_SLOT).setItem(createItem(Material.BARRIER, 1, lang("gui.clear-kit-button"), lang("gui.lore-shift-delete-kit"))); - addClearKit(menu.getSlot(CLEAR_SLOT), target, slot); - } - - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - openMenu(p, titledMenu); - setEditorContext(p, new EditorContext(EditorType.INSPECT_KIT, slot, null, target, playerName)); - SoundManager.playOpenGui(p); - } - - public void InspectEc(Player p, UUID target, int slot) { - String playerName = getPlayerName(target); - if (playerName == null) { - playerName = target.toString(); - } - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createInspectEcMenu(slot, playerName); - Menu menu = titledMenu.menu(); - - setGlassPaneRange(menu, 0, EC_CONTENT_START); - setGlassPaneRange(menu, EC_CONTENT_END, MENU_SIZE); - if (KitManager.get().getItemStackArrayById(target + "ec" + slot) != null) { - - ItemStack[] kit = KitManager.get().getItemStackArrayById(target + "ec" + slot); - for (int i = EC_CONTENT_START; i < EC_CONTENT_END; i++) { - menu.getSlot(i).setItem(kit[i - EC_CONTENT_START]); - } - } - - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.close-button"))); - menu.getSlot(BACK_SLOT).setClickHandler((player, info) -> { - SoundManager.playClick(player); - info.getClickedMenu().close(); - SoundManager.playCloseGui(player); - }); - - if (p.hasPermission("perplayerkit.admin")) { - allowModificationRange(menu, EC_CONTENT_START, EC_CONTENT_END); - menu.getSlot(CLEAR_SLOT).setItem(createItem(Material.BARRIER, 1, lang("gui.clear-ec-button"), lang("gui.lore-shift-delete-ec"))); - addClearEnderchest(menu.getSlot(CLEAR_SLOT), target, slot); - } - - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - openMenu(p, titledMenu); - setEditorContext(p, new EditorContext(EditorType.INSPECT_ENDERCHEST, slot, null, target, playerName)); - SoundManager.playOpenGui(p); - } - - public void OpenMainMenu(Player p) { - OpenMainMenu(p, 0); - } - - public void OpenMainMenu(Player p, int page) { - // Flush before reading kit data so the slot indicators reflect a kit - // the player just finished editing. - flushOpenEditor(p); - int maxKits = KitSlots.maxKits(); - int pages = KitSlots.pageCount(); - page = Ints.constrainToRange(page, 0, pages - 1); - int base = page * KitSlots.SLOTS_PER_PAGE; - if (pages > 1) { - lastMainMenuPage.put(p.getUniqueId(), page); - } - - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createMainMenu(p, page, pages); - Menu menu = titledMenu.menu(); - for (int i = 0; i < MENU_SIZE; i++) { - menu.getSlot(i).setItem(createGlassPane()); - } - // Three rows per kit slot: load/edit chest (row 1), enderchest (row 2), - // status book (row 3). Columns past maxKits stay glass. - for (int col = 0; col < KitSlots.SLOTS_PER_PAGE; col++) { - int slotNum = base + col + 1; - if (slotNum > maxKits) { - break; - } - - menu.getSlot(9 + col).setItem(createItem(Material.CHEST, 1, - lang("gui.kit-slot-name", "slot", String.valueOf(slotNum)), - lang("gui.lore-left-load"), lang("gui.lore-right-edit"))); - addEditLoad(menu.getSlot(9 + col), slotNum); - - if (KitManager.get().getItemStackArrayById(p.getUniqueId() + "ec" + slotNum) != null) { - menu.getSlot(18 + col).setItem(createItem(Material.ENDER_CHEST, 1, - lang("gui.enderchest-slot-name", "slot", String.valueOf(slotNum)), - lang("gui.lore-left-load"), lang("gui.lore-right-edit"))); - addEditLoadEC(menu.getSlot(18 + col), slotNum); - } else { - menu.getSlot(18 + col).setItem(createItem(Material.ENDER_EYE, 1, - lang("gui.enderchest-slot-name", "slot", String.valueOf(slotNum)), - lang("gui.lore-click-create"))); - addEditEC(menu.getSlot(18 + col), slotNum); - } - - if (KitManager.get().getItemStackArrayById(p.getUniqueId().toString() + slotNum) != null) { - menu.getSlot(27 + col).setItem(createItem(Material.KNOWLEDGE_BOOK, 1, lang("gui.kit-exists"), lang("gui.lore-click-edit"))); - } else { - menu.getSlot(27 + col).setItem(createItem(Material.BOOK, 1, lang("gui.kit-not-found"), lang("gui.lore-click-create"))); - } - addEdit(menu.getSlot(27 + col), slotNum); - } - - for (int i = 37; i < 44; i++) { - menu.getSlot(i).setItem(createGlassPane()); - } - - menu.getSlot(37).setItem(createItem(Material.NETHER_STAR, 1, lang("gui.kit-room-button"))); - menu.getSlot(38).setItem(createItem(Material.BOOKSHELF, 1, lang("gui.premade-kits-button"))); - menu.getSlot(39).setItem(createItem(Material.OAK_SIGN, 1, lang("gui.info-button"), - lang("gui.lore-info-load"), lang("gui.lore-info-edit"), lang("gui.lore-info-share"))); - menu.getSlot(41).setItem(createItem(Material.REDSTONE_BLOCK, 1, lang("gui.clear-inventory-button"), lang("gui.lore-shift-click"))); - menu.getSlot(42).setItem(createItem(Material.COMPASS, 1, lang("gui.share-kits-button"), lang("gui.lore-share-kits"))); - menu.getSlot(43).setItem(createItem(Material.EXPERIENCE_BOTTLE, 1, lang("gui.repair-items-button"))); - addRepairButton(menu.getSlot(43)); - addKitRoom(menu.getSlot(37)); - addPublicKitMenu(menu.getSlot(38)); - addClearButton(menu.getSlot(41)); - - if (page > 0) { - addPageArrow(menu, MAIN_PREV_PAGE_SLOT, "gui.previous-page-button", page - 1, pages); - } - if (page < pages - 1) { - addPageArrow(menu, MAIN_NEXT_PAGE_SLOT, "gui.next-page-button", page + 1, pages); - } - - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - openMenu(p, titledMenu); - } - - public void OpenKitRoom(Player p) { - OpenKitRoom(p, 0); } - public void OpenKitRoom(Player p, int page) { - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createKitRoomMenu(); - Menu menu = titledMenu.menu(); - allowModificationRange(menu, 0, FOOTER_START); - setGlassPaneRange(menu, FOOTER_START, MENU_SIZE); - if (KitRoomDataManager.get().getKitRoomPage(page) != null) { - for (int i = 0; i < FOOTER_START; i++) { - menu.getSlot(i).setItem(KitRoomDataManager.get().getKitRoomPage(page)[i]); - } - } - - menu.getSlot(45).setItem(createItem(Material.BEACON, 1, lang("gui.refill-button"))); - addKitRoom(menu.getSlot(45), page); - - if (!p.hasPermission("perplayerkit.editkitroom")) { - menu.getSlot(53).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - addMainButton(menu.getSlot(53)); - } else { - menu.getSlot(53).setItem(createItem(Material.BARRIER, page + 1, lang("gui.edit-menu-button"), lang("gui.edit-menu-lore"))); - addKitRoomSaveButton(menu.getSlot(53), page); - } - addKitRoom(menu.getSlot(47), 0); - addKitRoom(menu.getSlot(48), 1); - addKitRoom(menu.getSlot(49), 2); - addKitRoom(menu.getSlot(50), 3); - addKitRoom(menu.getSlot(51), 4); - - for (int i = 1; i < 6; i++) { - menu.getSlot(46 + i).setItem(addHideFlags(createItem(Material.valueOf(plugin.getConfig().getString("kitroom.items." + i + ".material")), "" + plugin.getConfig().getString("kitroom.items." + i + ".name")))); - } - - menu.getSlot(page + 47).setItem(ItemUtil.addEnchantLook(menu.getSlot(page + 47).getItem(p))); - - menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); - openMenu(p, titledMenu); + public static void forgetMainMenuPage(UUID player) { + ConfigurableGuiService.get().handlePlayerQuit(player); } - public void ViewPublicKitMenu(Player p, String id) { - ItemStack[] kit = KitManager.get().getPublicKit(id); - - if (kit == null) { - Lang.get().send(p, "error.kit-not-found-display"); - if (p.hasPermission("perplayerkit.admin")) { - Lang.get().send(p, "info.assign-publickit-instruction"); - } - return; - } - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createViewPublicKitMenu(id); - Menu menu = titledMenu.menu(); - - for (int i = 0; i < MENU_SIZE; i++) { - menu.getSlot(i).setItem(ItemUtil.createGlassPane()); - } - - for (int i = 9; i < 36; i++) { - menu.getSlot(i).setItem(kit[i]); - } - for (int i = 0; i < 9; i++) { - menu.getSlot(i + 36).setItem(kit[i]); - } - for (int i = 36; i < 41; i++) { - menu.getSlot(i + 9).setItem(kit[i]); - } - - setArmorAndOffhandIndicators(menu); - menu.getSlot(LOAD_PUBLIC_KIT_SLOT).setItem(createItem(Material.APPLE, 1, lang("gui.load-kit-button"))); - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - addPublicKitMenu(menu.getSlot(BACK_SLOT)); - addLoadPublicKit(menu.getSlot(LOAD_PUBLIC_KIT_SLOT), id); - - openMenu(p, titledMenu); + public void OpenMainMenu(Player player) { + ConfigurableGuiService.get().openMainMenu(player); } public void OpenPublicKitMenu(Player player) { - GuiMenuFactory.TitledMenu titledMenu = GuiMenuFactory.createPublicKitRoomMenu(); - Menu menu = titledMenu.menu(); - for (int i = 0; i < MENU_SIZE; i++) { - menu.getSlot(i).setItem(ItemUtil.createGlassPane()); - } - - for (int i = 18; i < 36; i++) { - menu.getSlot(i).setItem(ItemUtil.createItem(Material.BOOK, 1, lang("gui.more-kits-coming"))); - } - - List publicKitList = KitManager.get().getPublicKitList(); - - for (int i = 0; i < publicKitList.size(); i++) { - PublicKit kit = publicKitList.get(i); - if (KitManager.get().hasPublicKit(kit.id)) { - if (player.hasPermission("perplayerkit.admin")) { - menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, ChatColor.RESET + kit.name, lang("gui.lore-admin-shift-edit"))); - } else { - menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, ChatColor.RESET + kit.name)); - } - addPublicKitButton(menu.getSlot(i + 18), kit.id); - } else { - String unassignedName = ChatColor.RESET + kit.name + " " + lang("gui.unassigned-tag"); - if (player.hasPermission("perplayerkit.admin")) { - menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, unassignedName, - lang("gui.lore-unassigned-info"), lang("gui.lore-admin-shift-edit"))); - } else { - menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, unassignedName, lang("gui.lore-unassigned-info"))); - } - } - - if (player.hasPermission("perplayerkit.admin")) { - addAdminPublicKitButton(menu.getSlot(i + 18), kit.id); - } - } - - addMainButton(menu.getSlot(BACK_SLOT)); - menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button"))); - openMenu(player, titledMenu); - } - - public void addClear(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - Menu m = info.getClickedMenu(); - for (int i = 0; i < 41; i++) { - m.getSlot(i).setItem((org.bukkit.inventory.ItemStack) null); - } - } - }); - } - - public void addClear(Slot slot, int start, int end) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - Menu m = info.getClickedMenu(); - for (int i = start; i < end; i++) { - m.getSlot(i).setItem((org.bukkit.inventory.ItemStack) null); - } - } - }); - } - - public void addClearKit(Slot slot, UUID target, int slotNum) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - KitManager.get().deleteKit(target, slotNum); - Lang.get().send(player, "success.admin-kit-deleted", "slot", String.valueOf(slotNum)); - SoundManager.playSuccess(player); - kitDeletionFlag.add(player.getUniqueId()); - info.getClickedMenu().close(); - SoundManager.playCloseGui(player); - } - }); - } - - public void addClearEnderchest(Slot slot, UUID target, int slotNum) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - KitManager.get().deleteEnderchest(target, slotNum); - Lang.get().send(player, "success.admin-ec-deleted", "slot", String.valueOf(slotNum)); - SoundManager.playSuccess(player); - kitDeletionFlag.add(player.getUniqueId()); - info.getClickedMenu().close(); - SoundManager.playCloseGui(player); - } - }); - } - - public void addPublicKitButton(Slot slot, String id) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType() == ClickType.LEFT) { - KitManager.get().loadPublicKit(player, id); - info.getClickedMenu().close(); - } else if (info.getClickType() == ClickType.RIGHT) { - ViewPublicKitMenu(player, id); - } - }); - } - - public void addAdminPublicKitButton(Slot slot, String id) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - OpenPublicKitEditor(player, id); - return; - } - if (info.getClickType() == ClickType.LEFT) { - KitManager.get().loadPublicKit(player, id); - } else if (info.getClickType() == ClickType.RIGHT) { - ViewPublicKitMenu(player, id); - } - }); - } - - public void addMainButton(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - OpenMainMenu(player, lastMainMenuPage.getOrDefault(player.getUniqueId(), 0)); - }); - } - - public void addMainButton(Slot slot, int page) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - OpenMainMenu(player, page); - }); - } - - private void addPageArrow(Menu menu, int guiSlot, String nameKey, int targetPage, int pages) { - menu.getSlot(guiSlot).setItem(createItem(Material.ARROW, 1, lang(nameKey), - lang("gui.lore-page-indicator", "page", String.valueOf(targetPage + 1), "pages", String.valueOf(pages)))); - addMainButton(menu.getSlot(guiSlot), targetPage); - } - - public void addKitRoom(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - OpenKitRoom(player); - BroadcastManager.get().broadcastPlayerOpenedKitRoom(player); - }); - } - - public void addKitRoom(Slot slot, int page) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - OpenKitRoom(player, page); - }); - } - - public void addPublicKitMenu(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - OpenPublicKitMenu(player); - }); - } - - public void addKitRoomSaveButton(Slot slot, int page) { - slot.setClickHandler((player, info) -> { - if (!info.getClickType().isRightClick() || !info.getClickType().isShiftClick()) { - return; - } - if (!player.hasPermission("perplayerkit.editkitroom")) { - return; - } - Inventory top = player.getOpenInventory().getTopInventory(); - ItemStack[] data = new ItemStack[FOOTER_START]; - for (int i = 0; i < FOOTER_START; i++) { - ItemStack item = top.getItem(i); - data[i] = item == null ? null : item.clone(); - } - KitRoomDataManager.get().setKitRoom(page, data); - KitRoomDataManager.get().saveToDBAsync(); - Lang.get().send(player, "success.kitroom-menu-saved"); - SoundManager.playSuccess(player); - }); - } - - public void addRepairButton(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - BroadcastManager.get().broadcastPlayerRepaired(player); - PlayerUtil.repairAll(player); - player.updateInventory(); - SoundManager.playSuccess(player); - }); - } - - public void addClearButton(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isShiftClick()) { - player.getInventory().clear(); - Lang.get().send(player, "success.inventory-cleared"); - SoundManager.playSuccess(player); - } - }); - } - - public void addImport(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - Menu m = info.getClickedMenu(); - ItemStack[] inv; - if (filterItemsOnImport) { - inv = ItemFilter.get().filterItemStack(player.getInventory().getContents()); - } else { - inv = player.getInventory().getContents(); - } - for (int i = 0; i < 41; i++) { - m.getSlot(i).setItem(inv[i]); - } - }); - } - - public void addImportEC(Slot slot) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - Menu m = info.getClickedMenu(); - ItemStack[] inv; - if (filterItemsOnImport) { - inv = ItemFilter.get().filterItemStack(player.getEnderChest().getContents()); - } else { - inv = player.getEnderChest().getContents(); - } - for (int i = 0; i < 27; i++) { - m.getSlot(i + 9).setItem(inv[i]); - } - }); - } - - public void addEdit(Slot slot, int i) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isLeftClick() || info.getClickType().isRightClick()) { - OpenKitMenu(player, i); - } - }); - } - - public void addEditEC(Slot slot, int i) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType().isLeftClick() || info.getClickType().isRightClick()) { - OpenECKitKenu(player, i); - } - }); - } - - public void addLoad(Slot slot, int i) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) { - KitManager.get().loadKit(player, i); - info.getClickedMenu().close(); - SoundManager.playCloseGui(player); - } - }); + ConfigurableGuiService.get().openPublicKitMenu(player); } - public void addEditLoad(Slot slot, int i) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) { - KitManager.get().loadKit(player, i); - info.getClickedMenu().close(); - } else if (info.getClickType() == ClickType.RIGHT || info.getClickType() == ClickType.SHIFT_RIGHT) { - OpenKitMenu(player, i); - } - }); + public void InspectKit(Player player, UUID target, int slot) { + ConfigurableGuiService.get().openInspectKit(player, target, slot); } - public void addEditLoadEC(Slot slot, int i) { - slot.setClickHandler((player, info) -> { - SoundManager.playClick(player); - if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) { - KitManager.get().loadEnderchest(player, i); - info.getClickedMenu().close(); - } else if (info.getClickType() == ClickType.RIGHT || info.getClickType() == ClickType.SHIFT_RIGHT) { - OpenECKitKenu(player, i); - } - }); + public void InspectEc(Player player, UUID target, int slot) { + ConfigurableGuiService.get().openInspectEnderchest(player, target, slot); } } diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java b/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java index 47ca679..ff6bc2d 100644 --- a/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java +++ b/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java @@ -29,7 +29,7 @@ * compiles against: InventoryView#setTitle (1.20+) and * ItemMeta#setHideTooltip (1.20.5+). Both degrade gracefully on old servers. */ -final class GuiCompat { +public final class GuiCompat { private static final Method SET_TITLE = findMethod(InventoryView.class, "setTitle", String.class); private static final Method SET_HIDE_TOOLTIP = findMethod(ItemMeta.class, "setHideTooltip", boolean.class); @@ -50,11 +50,11 @@ private static Method findMethod(Class owner, String name, Class... params * When false, menus must reopen so titles stay correct, at the cost of the * client recentering the mouse cursor. */ - static boolean supportsTitleUpdate() { + public static boolean supportsTitleUpdate() { return SET_TITLE != null; } - static void updateTitle(Player player, String title) { + public static void updateTitle(Player player, String title) { if (SET_TITLE == null || title == null) { return; } diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java b/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java deleted file mode 100644 index dca918a..0000000 --- a/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2022-2025 Noah Ross - * - * This file is part of PerPlayerKit. - * - * PerPlayerKit is free software: you can redistribute it and/or modify it under - * the terms of the GNU Affero General Public License as published by the - * Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for - * more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with PerPlayerKit. If not, see . - */ -package dev.noah.perplayerkit.gui; - -import org.bukkit.Material; -import org.ipvp.canvas.Menu; -import org.ipvp.canvas.slot.ClickOptions; - -import static dev.noah.perplayerkit.gui.ItemUtil.createGlassPane; -import static dev.noah.perplayerkit.gui.ItemUtil.createItem; - -public final class GuiLayoutUtils { - public static final int MENU_SIZE = 54; - public static final int KIT_CONTENT_END = 41; - public static final int EC_CONTENT_START = 9; - public static final int EC_CONTENT_END = 36; - public static final int FOOTER_START = 45; - public static final int ARMOR_INDICATOR_START = 45; - public static final int OFFHAND_INDICATOR_SLOT = 49; - public static final int IMPORT_SLOT = 51; - public static final int LOAD_PUBLIC_KIT_SLOT = 52; - public static final int CLEAR_SLOT = 52; - public static final int BACK_SLOT = 53; - public static final int MAIN_PREV_PAGE_SLOT = 45; - public static final int MAIN_NEXT_PAGE_SLOT = 53; - - private GuiLayoutUtils() { - } - - public static void allowModificationRange(Menu menu, int startInclusive, int endExclusive) { - for (int i = startInclusive; i < endExclusive; i++) { - menu.getSlot(i).setClickOptions(ClickOptions.ALLOW_ALL); - } - } - - public static void setGlassPaneRange(Menu menu, int startInclusive, int endExclusive) { - for (int i = startInclusive; i < endExclusive; i++) { - menu.getSlot(i).setItem(createGlassPane()); - } - } - - public static void setArmorAndOffhandIndicators(Menu menu) { - menu.getSlot(ARMOR_INDICATOR_START).setItem(createItem(Material.CHAINMAIL_BOOTS, 1, "BOOTS")); - menu.getSlot(ARMOR_INDICATOR_START + 1).setItem(createItem(Material.CHAINMAIL_LEGGINGS, 1, "LEGGINGS")); - menu.getSlot(ARMOR_INDICATOR_START + 2).setItem(createItem(Material.CHAINMAIL_CHESTPLATE, 1, "CHESTPLATE")); - menu.getSlot(ARMOR_INDICATOR_START + 3).setItem(createItem(Material.CHAINMAIL_HELMET, 1, "HELMET")); - menu.getSlot(OFFHAND_INDICATOR_SLOT).setItem(createItem(Material.SHIELD, 1, "OFFHAND")); - } -} diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java b/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java deleted file mode 100644 index e56c300..0000000 --- a/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2022-2025 Noah Ross - * - * This file is part of PerPlayerKit. - * - * PerPlayerKit is free software: you can redistribute it and/or modify it under - * the terms of the GNU Affero General Public License as published by the - * Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for - * more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with PerPlayerKit. If not, see . - */ -package dev.noah.perplayerkit.gui; - -import dev.noah.perplayerkit.util.Lang; -import dev.noah.perplayerkit.util.StyleManager; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import org.bukkit.entity.Player; -import org.ipvp.canvas.Menu; -import org.ipvp.canvas.type.ChestMenu; - -public final class GuiMenuFactory { - private GuiMenuFactory() { - } - - /** A menu paired with the title it was built with, since canvas doesn't expose it. */ - public record TitledMenu(Menu menu, String title) { - } - - private static String title(String key) { - return StyleManager.get().getPrimaryColor() - + LegacyComponentSerializer.legacySection().serialize(MiniMessage.miniMessage().deserialize(Lang.get().raw(key))); - } - - private static String title(String key, String... pairs) { - return StyleManager.get().getPrimaryColor() - + LegacyComponentSerializer.legacySection().serialize(MiniMessage.miniMessage().deserialize(Lang.get().raw(key, pairs))); - } - - private static TitledMenu chestMenu(String title) { - // Redraw makes canvas swap menus inside the already-open inventory, so - // the client keeps its cursor position instead of recentering on a - // reopen. The reused inventory keeps its old title, which GUI fixes up - // afterwards — only possible when the server has InventoryView#setTitle. - Menu menu = ChestMenu.builder(6).title(title).redraw(GuiCompat.supportsTitleUpdate()).build(); - return new TitledMenu(menu, title); - } - - public static TitledMenu createPublicKitRoomMenu() { - return chestMenu(title("gui.public-kit-room-title")); - } - - public static TitledMenu createKitMenu(int slot) { - return chestMenu(title("gui.kit-editor-title", "slot", String.valueOf(slot))); - } - - public static TitledMenu createPublicKitMenu(String id) { - return chestMenu(title("gui.public-kit-editor-title", "id", id)); - } - - public static TitledMenu createECMenu(int slot) { - return chestMenu(title("gui.enderchest-editor-title", "slot", String.valueOf(slot))); - } - - public static TitledMenu createInspectMenu(int slot, String playerName) { - return chestMenu(title("gui.inspect-kit-title", "player", playerName, "slot", String.valueOf(slot))); - } - - public static TitledMenu createInspectEcMenu(int slot, String playerName) { - return chestMenu(title("gui.inspect-ec-title", "player", playerName, "slot", String.valueOf(slot))); - } - - public static TitledMenu createMainMenu(Player player, int page, int pages) { - if (pages <= 1) { - return chestMenu(title("gui.main-menu-title", "player", player.getName())); - } - return chestMenu(title("gui.main-menu-title-paged", - "player", player.getName(), - "page", String.valueOf(page + 1), - "pages", String.valueOf(pages))); - } - - public static TitledMenu createKitRoomMenu() { - return chestMenu(title("gui.kit-room-title")); - } - - public static TitledMenu createViewPublicKitMenu(String id) { - return chestMenu(title("gui.view-public-kit-title", "id", id)); - } -} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java new file mode 100644 index 0000000..6e1e7be --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java @@ -0,0 +1,1081 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import com.google.common.primitives.Ints; +import dev.noah.perplayerkit.ItemFilter; +import dev.noah.perplayerkit.KitManager; +import dev.noah.perplayerkit.KitRoomDataManager; +import dev.noah.perplayerkit.PublicKit; +import dev.noah.perplayerkit.gui.GuiCompat; +import dev.noah.perplayerkit.gui.ItemUtil; +import dev.noah.perplayerkit.gui.configurable.EditorSession.EditorType; +import dev.noah.perplayerkit.util.BroadcastManager; +import dev.noah.perplayerkit.util.IDUtil; +import dev.noah.perplayerkit.util.KitSlots; +import dev.noah.perplayerkit.util.Lang; +import dev.noah.perplayerkit.util.PlayerUtil; +import dev.noah.perplayerkit.util.SoundManager; +import dev.noah.perplayerkit.util.StyleManager; +import me.clip.placeholderapi.PlaceholderAPI; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.ipvp.canvas.Menu; +import org.ipvp.canvas.slot.ClickOptions; +import org.ipvp.canvas.slot.Slot; +import org.ipvp.canvas.type.ChestMenu; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.IntPredicate; + +/** + * Builds and opens the plugin's menus from the layouts in guis.yml. + * + *

Menu structure (slots, items, text, click actions) is config-driven; + * behavior stays in code as named components and action types the config + * refers to. Editor saving reuses the session model proven in the old GUI + * class: an {@link EditorSession} is registered when an editor opens, flushed + * either by InventoryCloseEvent or before opening the next menu — the latter + * because canvas redraw navigation reuses the open inventory and never fires + * a close event. + */ +public class ConfigurableGuiService { + + private static final String MAIN_MENU = "main-menu"; + private static final String PLAYER_KIT_EDITOR = "player-kit-editor"; + private static final String PUBLIC_KIT_EDITOR = "public-kit-editor"; + private static final String ENDERCHEST_EDITOR = "enderchest-editor"; + private static final String INSPECT_KIT = "inspect-kit"; + private static final String INSPECT_ENDERCHEST = "inspect-enderchest"; + private static final String KIT_ROOM = "kit-room"; + private static final String PUBLIC_KIT_MENU = "public-kit-menu"; + private static final String PUBLIC_KIT_VIEWER = "public-kit-viewer"; + private static final String VIEW_ONLY_ENDERCHEST = "view-only-enderchest"; + + private static final int KIT_DATA_SIZE = 41; + private static final int EC_DATA_SIZE = 27; + + private static ConfigurableGuiService instance; + + private final Plugin plugin; + private final GuiConfigManager guiConfig; + private final boolean filterItemsOnImport; + private final Map editorSessions = new HashMap<>(); + // Set by delete actions so closing the editor afterwards does not + // immediately re-save the deleted kit. + private final Set skipNextSave = new HashSet<>(); + // Last main-menu page each player viewed, so back buttons from submenus + // return to it instead of resetting to page 1. + private final Map lastMainMenuPage = new HashMap<>(); + // Data-component slot lists, re-resolved on every editor open otherwise. + // The config is parsed once at startup, so entries never go stale. + private final Map> componentSlotsCache = new HashMap<>(); + + public ConfigurableGuiService(Plugin plugin) { + this.plugin = plugin; + this.guiConfig = new GuiConfigManager(plugin); + this.filterItemsOnImport = plugin.getConfig().getBoolean("anti-exploit.import-filter", false); + instance = this; + } + + public static ConfigurableGuiService get() { + if (instance == null) { + throw new IllegalStateException("ConfigurableGuiService has not been initialized"); + } + return instance; + } + + // ------------------------------------------------------------------ + // Entry points + // ------------------------------------------------------------------ + + public void openMainMenu(Player player) { + // Commands always start on page 1; only submenu back buttons resume + // the remembered page (see executeOpenGui). + openMainMenu(player, 0); + } + + public void openMainMenu(Player player, int page) { + int pages = KitSlots.pageCount(); + page = Ints.constrainToRange(page, 0, pages - 1); + if (pages > 1) { + lastMainMenuPage.put(player.getUniqueId(), page); + } + + openGui(MAIN_MENU, player, GuiContext.empty() + .with("player", player.getName()) + .with("page_index", page) + .with("pages", pages)); + } + + public void openPlayerKitEditor(Player player, int slot) { + GuiContext context = GuiContext.empty() + .with("slot", slot) + .with("slot_page", KitSlots.pageOf(slot)); + if (openGui(PLAYER_KIT_EDITOR, player, context) != null) { + registerEditor(player, PLAYER_KIT_EDITOR, "kit-data", EditorType.KIT, slot, null, null, null); + } + } + + public void openPublicKitEditor(Player player, String publicKitId) { + GuiContext context = enrichPublicKitContext(GuiContext.empty().with("id", publicKitId), publicKitId); + if (openGui(PUBLIC_KIT_EDITOR, player, context) != null) { + registerEditor(player, PUBLIC_KIT_EDITOR, "kit-data", EditorType.PUBLIC_KIT, 0, publicKitId, null, null); + } + } + + public void openEnderchestEditor(Player player, int slot) { + GuiContext context = GuiContext.empty() + .with("slot", slot) + .with("slot_page", KitSlots.pageOf(slot)); + if (openGui(ENDERCHEST_EDITOR, player, context) != null) { + registerEditor(player, ENDERCHEST_EDITOR, "enderchest-data", EditorType.ENDERCHEST, slot, null, null, null); + } + } + + public void openInspectKit(Player player, UUID target, int slot) { + String targetName = PlayerUtil.getPlayerName(target); + GuiContext context = GuiContext.empty() + .with("slot", slot) + .with("player", targetName) + .with("target_uuid", target) + .with("target_name", targetName); + if (openGui(INSPECT_KIT, player, context) != null) { + registerEditor(player, INSPECT_KIT, "kit-data", EditorType.INSPECT_KIT, slot, null, target, targetName); + } + } + + public void openInspectEnderchest(Player player, UUID target, int slot) { + String targetName = PlayerUtil.getPlayerName(target); + GuiContext context = GuiContext.empty() + .with("slot", slot) + .with("player", targetName) + .with("target_uuid", target) + .with("target_name", targetName); + if (openGui(INSPECT_ENDERCHEST, player, context) != null) { + registerEditor(player, INSPECT_ENDERCHEST, "enderchest-data", EditorType.INSPECT_ENDERCHEST, slot, null, target, targetName); + } + } + + public void openKitRoom(Player player) { + openKitRoom(player, 0); + } + + public void openKitRoom(Player player, int page) { + page = Ints.constrainToRange(page, 0, KitRoomDataManager.get().pageCount() - 1); + openGui(KIT_ROOM, player, GuiContext.empty().with("page_index", page)); + } + + public void openPublicKitMenu(Player player) { + openGui(PUBLIC_KIT_MENU, player, GuiContext.empty()); + } + + public void openPublicKitViewer(Player player, String publicKitId) { + if (KitManager.get().getPublicKit(publicKitId) == null) { + Lang.get().send(player, "error.kit-not-found-display"); + if (player.hasPermission("perplayerkit.admin")) { + Lang.get().send(player, "info.assign-publickit-instruction"); + } + return; + } + openGui(PUBLIC_KIT_VIEWER, player, enrichPublicKitContext(GuiContext.empty().with("id", publicKitId), publicKitId)); + } + + public void openViewOnlyEnderchest(Player player) { + openGui(VIEW_ONLY_ENDERCHEST, player, GuiContext.empty()); + } + + // ------------------------------------------------------------------ + // Editor sessions + // ------------------------------------------------------------------ + + /** Invoked by KitMenuCloseListener when a menu inventory actually closes. */ + public void handleInventoryClose(Player player, Inventory inventory) { + flush(player, inventory); + } + + public void handlePlayerQuit(UUID player) { + editorSessions.remove(player); + skipNextSave.remove(player); + lastMainMenuPage.remove(player); + } + + private void registerEditor(Player player, String guiId, String dataComponent, EditorType type, + int slot, String publicKitId, UUID target, String targetName) { + ConfigurationSection guiSection = guiConfig.getGuiSection(guiId); + if (guiSection == null) { + return; + } + int menuSize = rows(guiSection) * 9; + List dataSlots = componentSlots(guiId, dataComponent); + if (dataSlots.isEmpty()) { + plugin.getLogger().warning("GUI '" + guiId + "' has no " + dataComponent + " component; edits will not be saved"); + return; + } + skipNextSave.remove(player.getUniqueId()); + editorSessions.put(player.getUniqueId(), + new EditorSession(type, slot, publicKitId, target, targetName, dataSlots, menuSize)); + } + + private void flush(Player player, Inventory inventory) { + EditorSession session = editorSessions.get(player.getUniqueId()); + if (session == null || !session.matches(inventory)) { + return; + } + editorSessions.remove(player.getUniqueId()); + saveEditor(player, session, inventory); + } + + private void flushOpenEditor(Player player) { + flush(player, player.getOpenInventory().getTopInventory()); + // Opening another menu invalidates any editor session (and pending + // skip flag) regardless of whether the open inventory still matched. + editorSessions.remove(player.getUniqueId()); + skipNextSave.remove(player.getUniqueId()); + } + + private void saveEditor(Player player, EditorSession session, Inventory inventory) { + if (skipNextSave.remove(player.getUniqueId())) { + return; + } + + switch (session.type()) { + case KIT -> KitManager.get().savekit(player.getUniqueId(), session.slot(), + session.extractContents(inventory, KIT_DATA_SIZE)); + case PUBLIC_KIT -> { + if (session.publicKitId() != null && !session.publicKitId().isEmpty()) { + KitManager.get().savePublicKit(player, session.publicKitId(), + session.extractContents(inventory, KIT_DATA_SIZE)); + } + } + case ENDERCHEST -> KitManager.get().saveEC(player.getUniqueId(), session.slot(), + session.extractContents(inventory, EC_DATA_SIZE)); + case INSPECT_KIT -> { + if (!player.hasPermission("perplayerkit.admin") || session.target() == null) { + return; + } + if (KitManager.get().savekit(session.target(), session.slot(), + session.extractContents(inventory, KIT_DATA_SIZE), true)) { + Lang.get().send(player, "success.admin-kit-updated", + "slot", String.valueOf(session.slot()), "player", session.targetName()); + } else { + Lang.get().send(player, "error.failed-to-update-kit", "player", session.targetName()); + } + } + case INSPECT_ENDERCHEST -> { + if (!player.hasPermission("perplayerkit.admin") || session.target() == null) { + return; + } + if (KitManager.get().saveECSilent(session.target(), session.slot(), + session.extractContents(inventory, EC_DATA_SIZE))) { + Lang.get().send(player, "success.admin-ec-updated", + "slot", String.valueOf(session.slot()), "player", session.targetName()); + } else { + Lang.get().send(player, "error.failed-to-update-ec", "player", session.targetName()); + } + } + } + } + + // ------------------------------------------------------------------ + // Menu construction + // ------------------------------------------------------------------ + + private Menu openGui(String guiId, Player viewer, GuiContext context) { + // Flush before rendering, not just before opening: components read kit + // data that a still-open editor may not have persisted yet. + flushOpenEditor(viewer); + + ConfigurationSection guiSection = guiConfig.getGuiSection(guiId); + if (guiSection == null) { + plugin.getLogger().warning("Missing GUI definition: " + guiId); + return null; + } + + // The 1-based display page always accompanies the 0-based logic page, + // so titles and lore can use {page} wherever page_index flows. + if (context.has("page_index") && !context.has("page")) { + context = context.with("page", context.getInt("page_index", 0) + 1); + } + + int rows = rows(guiSection); + String title = resolveTitle(guiSection, viewer, context); + + // Redraw lets canvas swap menus inside the already-open inventory so + // the client keeps its cursor position; the stale title it leaves + // behind is updated in place below. Only safe with setTitle support. + Menu menu = ChestMenu.builder(rows) + .title(title) + .redraw(GuiCompat.supportsTitleUpdate()) + .build(); + menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING); + + for (ConfigurationSection elementSection : orderedElements(guiSection)) { + renderElement(menu, viewer, context, elementSection, rows * 9); + } + + menu.open(viewer); + GuiCompat.updateTitle(viewer, title); + if (guiSection.getBoolean("open-sound", false)) { + SoundManager.playOpenGui(viewer); + } + return menu; + } + + private int rows(ConfigurationSection guiSection) { + return Ints.constrainToRange(guiSection.getInt("rows", 6), 1, 6); + } + + private String resolveTitle(ConfigurationSection guiSection, Player viewer, GuiContext context) { + String titleKey = "title"; + Integer pages = context.getInt("pages"); + if (pages != null && pages > 1 && guiSection.isString("title-paged")) { + titleKey = "title-paged"; + } + String resolved = resolveText(guiSection.getString(titleKey, "Menu"), viewer, context); + return StyleManager.convertMiniMessage(resolved); + } + + private List orderedElements(ConfigurationSection guiSection) { + ConfigurationSection elementsSection = guiSection.getConfigurationSection("elements"); + if (elementsSection == null) { + return Collections.emptyList(); + } + + List sections = new ArrayList<>(); + for (String key : elementsSection.getKeys(false)) { + ConfigurationSection section = elementsSection.getConfigurationSection(key); + if (section != null) { + sections.add(section); + } + } + // Stable sort: explicit "order" keys guarantee layering (fill under + // data under buttons) even for elements merged in from YAML templates, + // whose iteration position is parser-defined. + sections.sort((left, right) -> Integer.compare(left.getInt("order", 0), right.getInt("order", 0))); + return sections; + } + + private void renderElement(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) { + if (!isVisibleToViewer(elementSection, viewer)) { + return; + } + + String type = elementSection.getString("type", "static").toLowerCase(Locale.ROOT); + switch (type) { + case "fill", "static" -> renderStaticElement(menu, viewer, context, elementSection, menuSize); + case "component" -> renderComponent(menu, viewer, context, elementSection, menuSize); + default -> plugin.getLogger().warning("Unknown GUI element type '" + type + "' in " + elementSection.getCurrentPath()); + } + } + + private void renderStaticElement(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) { + ItemStack item = buildItem(elementSection.getConfigurationSection("item"), viewer, context); + ConfigurationSection actionsSection = elementSection.getConfigurationSection("actions"); + boolean editable = editableFor(elementSection, viewer); + + for (int slotIndex : parseSlots(elementSection.get("slots"), menuSize)) { + Slot slot = menu.getSlot(slotIndex); + if (item != null) { + slot.setItem(item.clone()); + } + if (editable) { + slot.setClickOptions(ClickOptions.ALLOW_ALL); + } + bindActions(slot, actionsSection, context); + } + } + + private void renderComponent(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) { + String component = elementSection.getString("component", "").toLowerCase(Locale.ROOT); + List slots = parseSlots(elementSection.get("slots"), menuSize); + + switch (component) { + case "kit-slot-selector" -> renderKitSlotSelector(menu, viewer, slots, elementSection, context); + case "main-menu-pagination" -> renderMainMenuPagination(menu, viewer, elementSection, context, menuSize); + case "public-kit-list" -> renderPublicKitList(menu, viewer, slots, elementSection, context); + case "kit-data" -> renderItemData(menu, viewer, slots, elementSection, resolveKitData(viewer, context)); + case "enderchest-data" -> renderItemData(menu, viewer, slots, elementSection, resolveEnderchestData(viewer, context)); + case "player-enderchest-data" -> renderItemData(menu, viewer, slots, elementSection, viewer.getEnderChest().getContents()); + case "kit-room-data" -> renderItemData(menu, viewer, slots, elementSection, + KitRoomDataManager.get().getKitRoomPage(context.getInt("page_index", 0))); + case "kit-room-category-buttons" -> renderKitRoomCategoryButtons(menu, viewer, slots, elementSection, context); + default -> plugin.getLogger().warning("Unknown GUI component '" + component + "' in " + elementSection.getCurrentPath()); + } + } + + /** + * One button per kit slot on the current main-menu page. Kit slot numbers + * are derived from the page in the context, so the same element serves + * every page of a raised max-kits limit. "source: enderchest" checks + * enderchest existence for the exists/missing variants; default is kits. + */ + private void renderKitSlotSelector(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) { + boolean enderchest = "enderchest".equalsIgnoreCase(elementSection.getString("source", "kit")); + IntPredicate exists = enderchest + ? slotNumber -> KitManager.get().hasEC(viewer.getUniqueId(), slotNumber) + : slotNumber -> KitManager.get().hasKit(viewer.getUniqueId(), slotNumber); + int page = context.getInt("page_index", 0); + + for (int i = 0; i < slots.size(); i++) { + int slotNumber = page * KitSlots.SLOTS_PER_PAGE + i + 1; + if (slotNumber > KitSlots.maxKits()) { + break; + } + + GuiContext slotContext = context.with("slot", slotNumber); + String variant = exists.test(slotNumber) ? "exists" : "missing"; + applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext); + } + } + + private void renderMainMenuPagination(Menu menu, Player viewer, ConfigurationSection elementSection, GuiContext context, int menuSize) { + int page = context.getInt("page_index", 0); + int pages = context.getInt("pages", 1); + + if (page > 0) { + applyPageArrow(menu, viewer, elementSection, "previous", elementSection.get("previous-slot"), page - 1, pages, menuSize); + } + if (page < pages - 1) { + applyPageArrow(menu, viewer, elementSection, "next", elementSection.get("next-slot"), page + 1, pages, menuSize); + } + } + + private void applyPageArrow(Menu menu, Player viewer, ConfigurationSection elementSection, String variant, + Object slotDeclaration, int targetPage, int pages, int menuSize) { + List slots = parseSlots(slotDeclaration, menuSize); + if (slots.isEmpty()) { + return; + } + GuiContext arrowContext = GuiContext.empty() + .with("page_index", targetPage) + .with("page", targetPage + 1) + .with("pages", pages); + applyVariant(menu.getSlot(slots.get(0)), viewer, elementSection, variant, arrowContext); + } + + private void renderPublicKitList(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) { + List publicKits = KitManager.get().getPublicKitList(); + boolean admin = viewer.hasPermission("perplayerkit.admin"); + + if (publicKits.size() > slots.size()) { + plugin.getLogger().warning("public-kit-list has " + slots.size() + " slots but " + publicKits.size() + + " public kits are defined; the rest are not shown"); + } + + for (int i = 0; i < Math.min(slots.size(), publicKits.size()); i++) { + PublicKit publicKit = publicKits.get(i); + boolean assigned = KitManager.get().hasPublicKit(publicKit.id); + String variant = (admin ? "admin_" : "") + (assigned ? "assigned" : "unassigned"); + + GuiContext slotContext = context + .with("id", publicKit.id) + .with("public_kit_name", publicKit.name) + .with("public_kit_icon", publicKit.icon.name()); + + applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext); + } + } + + private void renderItemData(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, ItemStack[] data) { + boolean editable = editableFor(elementSection, viewer); + + for (int i = 0; i < slots.size(); i++) { + Slot slot = menu.getSlot(slots.get(i)); + ItemStack item = data != null && i < data.length && data[i] != null ? data[i].clone() : null; + slot.setItem(item); + if (editable) { + slot.setClickOptions(ClickOptions.ALLOW_ALL); + } + } + } + + private void renderKitRoomCategoryButtons(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) { + ConfigurationSection categories = plugin.getConfig().getConfigurationSection("kitroom.items"); + int categoryCount = categories != null ? categories.getKeys(false).size() : 0; + int pages = KitRoomDataManager.get().pageCount(); + if (categoryCount != slots.size() || categoryCount > pages) { + plugin.getLogger().warning("kit-room-category-buttons has " + slots.size() + " slots for " + + categoryCount + " kitroom categories (storage holds " + pages + " pages)"); + } + + int currentPage = context.getInt("page_index", 0); + for (int i = 0; i < Math.min(Math.min(slots.size(), categoryCount), pages); i++) { + String basePath = "kitroom.items." + (i + 1); + GuiContext slotContext = context + .with("page_index", i) + .with("page", i + 1) + .with("kitroom_name", plugin.getConfig().getString(basePath + ".name", "Page " + (i + 1))) + .with("kitroom_material", plugin.getConfig().getString(basePath + ".material", "BOOK")); + + String variant = i == currentPage ? "active" : "default"; + applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext); + } + } + + private void applyVariant(Slot slot, Player viewer, ConfigurationSection elementSection, String variantName, GuiContext context) { + List chain = variantChain(elementSection, variantName); + + ConfigurationSection itemSource = firstDefining(chain, "item"); + ItemStack item = itemSource != null ? buildItem(itemSource.getConfigurationSection("item"), viewer, context) : null; + if (item != null) { + slot.setItem(item); + } + + ConfigurationSection editableSource = firstDefining(chain, "editable"); + if (editableSource != null && editableFor(editableSource, viewer)) { + slot.setClickOptions(ClickOptions.ALLOW_ALL); + } + + ConfigurationSection actionsSource = firstDefining(chain, "actions"); + bindActions(slot, actionsSource != null ? actionsSource.getConfigurationSection("actions") : null, context); + } + + /** + * Per-key fallback order for a variant: the named variant, then + * "variants.default", then the element itself — so a variant that only + * overrides its item still inherits shared actions or editability. + */ + private List variantChain(ConfigurationSection elementSection, String variantName) { + ConfigurationSection variantsSection = elementSection.getConfigurationSection("variants"); + if (variantsSection == null) { + return List.of(elementSection); + } + + List chain = new ArrayList<>(3); + ConfigurationSection named = variantsSection.getConfigurationSection(variantName); + if (named != null) { + chain.add(named); + } + ConfigurationSection defaultSection = variantsSection.getConfigurationSection("default"); + if (defaultSection != null && defaultSection != named) { + chain.add(defaultSection); + } + chain.add(elementSection); + return chain; + } + + private ConfigurationSection firstDefining(List chain, String key) { + for (ConfigurationSection section : chain) { + if (section.contains(key)) { + return section; + } + } + return null; + } + + private boolean isVisibleToViewer(ConfigurationSection section, Player viewer) { + String permission = section.getString("permission"); + if (permission != null && !permission.isEmpty() && !viewer.hasPermission(permission)) { + return false; + } + String excludedPermission = section.getString("exclude-permission"); + return excludedPermission == null || excludedPermission.isEmpty() || !viewer.hasPermission(excludedPermission); + } + + /** + * "editable" is either a boolean or a permission node that grants editing + * to viewers who hold it. + */ + private boolean editableFor(ConfigurationSection section, Player viewer) { + if (section.isBoolean("editable")) { + return section.getBoolean("editable"); + } + String permission = section.getString("editable"); + return permission != null && !permission.isEmpty() && viewer.hasPermission(permission); + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + private void bindActions(Slot slot, ConfigurationSection actionsSection, GuiContext context) { + if (actionsSection == null) { + return; + } + + slot.setClickHandler((player, info) -> { + // Feedback on every click of an interactive slot, even when the + // click type matches no action (e.g. left click on a shift-only + // button) — a silent button reads as broken. + SoundManager.playClick(player); + for (Map action : actionsForClick(actionsSection, info.getClickType())) { + executeAction(player, info.getClickedMenu(), context, action); + } + }); + } + + /** + * Picks the action list for a click, most specific key first: the exact + * type ("shift_left"), then "shift", "left"/"right"/"middle", then "any". + */ + private List> actionsForClick(ConfigurationSection actionsSection, ClickType clickType) { + List candidates = new ArrayList<>(4); + candidates.add(clickType.name().toLowerCase(Locale.ROOT)); + if (clickType.isShiftClick()) { + candidates.add("shift"); + } + boolean mouseButton = false; + if (clickType.isLeftClick()) { + candidates.add("left"); + mouseButton = true; + } else if (clickType.isRightClick()) { + candidates.add("right"); + mouseButton = true; + } else if (clickType == ClickType.MIDDLE) { + candidates.add("middle"); + mouseButton = true; + } + // Keyboard-driven clicks (number keys, drop, offhand swap) only match + // explicitly named keys; "any" means any mouse button. + if (mouseButton) { + candidates.add("any"); + } + + for (String candidate : candidates) { + List> actions = actionList(actionsSection, candidate); + if (!actions.isEmpty()) { + return actions; + } + } + return Collections.emptyList(); + } + + private List> actionList(ConfigurationSection actionsSection, String path) { + List rawActions = actionsSection.getList(path); + if (rawActions == null || rawActions.isEmpty()) { + return Collections.emptyList(); + } + + List> actions = new ArrayList<>(rawActions.size()); + for (Object rawAction : rawActions) { + if (rawAction instanceof Map map) { + actions.add(map); + } + } + return actions; + } + + private void executeAction(Player player, Menu menu, GuiContext context, Map action) { + String type = stringValue(action, "type"); + if (type == null || type.isEmpty()) { + return; + } + + String permission = stringValue(action, "permission"); + if (permission != null && !permission.isEmpty() && !player.hasPermission(permission)) { + return; + } + + switch (type.toLowerCase(Locale.ROOT)) { + case "open-gui" -> executeOpenGui(player, context, action); + case "load-player-kit" -> { + KitManager.get().loadKit(player, actionInt(action, "slot", context)); + closeAfterAction(player, menu, action); + } + case "load-enderchest" -> { + KitManager.get().loadEnderchest(player, actionInt(action, "slot", context)); + closeAfterAction(player, menu, action); + } + case "load-public-kit" -> { + String publicKitId = actionString(action, "public-kit-id", context, context.getString("id")); + if (publicKitId != null && !publicKitId.isEmpty()) { + KitManager.get().loadPublicKit(player, publicKitId); + } + closeAfterAction(player, menu, action); + } + case "close" -> { + menu.close(player); + SoundManager.playCloseGui(player); + } + case "clear-editor" -> clearEditorSlots(player, menu, action); + case "import-player-inventory" -> importIntoEditor(player, menu, action, filteredContents(player.getInventory().getContents())); + case "import-player-enderchest" -> importIntoEditor(player, menu, action, filteredContents(player.getEnderChest().getContents())); + case "clear-player-inventory" -> { + player.getInventory().clear(); + Lang.get().send(player, "success.inventory-cleared"); + SoundManager.playSuccess(player); + } + case "repair-player-items" -> { + BroadcastManager.get().broadcastPlayerRepaired(player); + PlayerUtil.repairAll(player); + player.updateInventory(); + SoundManager.playSuccess(player); + } + case "delete-player-kit" -> deleteInspectedKit(player, menu, context, action, false); + case "delete-player-enderchest" -> deleteInspectedKit(player, menu, context, action, true); + case "save-kit-room-page" -> saveKitRoomPage(player, context); + case "broadcast-kit-room-opened" -> BroadcastManager.get().broadcastPlayerOpenedKitRoom(player); + default -> plugin.getLogger().warning("Unknown GUI action type '" + type + "'"); + } + } + + /** + * Editors open through their typed entry points so an + * {@link EditorSession} is registered, and main menu / kit room through + * theirs so paging context is normalized (page memory, clamping); + * everything else — including menus server owners add themselves — opens + * generically. + */ + private void executeOpenGui(Player player, GuiContext context, Map action) { + String guiId = stringValue(action, "gui"); + if (guiId == null || guiId.isEmpty()) { + return; + } + + GuiContext nextContext = actionContext(context, action.get("context")); + switch (guiId) { + case MAIN_MENU -> { + Integer page = nextContext.getInt("page_index"); + openMainMenu(player, page != null ? page : lastMainMenuPage.getOrDefault(player.getUniqueId(), 0)); + } + case KIT_ROOM -> openKitRoom(player, nextContext.getInt("page_index", 0)); + case PLAYER_KIT_EDITOR -> openPlayerKitEditor(player, nextContext.getInt("slot", 1)); + case ENDERCHEST_EDITOR -> openEnderchestEditor(player, nextContext.getInt("slot", 1)); + case PUBLIC_KIT_EDITOR -> openPublicKitEditor(player, nextContext.getString("id")); + case PUBLIC_KIT_VIEWER -> openPublicKitViewer(player, nextContext.getString("id")); + default -> openGui(guiId, player, nextContext); + } + } + + private void deleteInspectedKit(Player player, Menu menu, GuiContext context, Map action, boolean enderchest) { + UUID target = context.getUuid("target_uuid"); + if (target == null) { + return; + } + int slot = actionInt(action, "slot", context); + + boolean success = enderchest + ? KitManager.get().deleteEnderchest(target, slot) + : KitManager.get().deleteKit(target, slot); + if (success) { + Lang.get().send(player, enderchest ? "success.admin-ec-deleted" : "success.admin-kit-deleted", + "slot", String.valueOf(slot)); + SoundManager.playSuccess(player); + } else { + SoundManager.playFailure(player); + } + + // Even a failed delete (kit already gone, e.g. deleted by another + // admin mid-inspect) must suppress the close-save: the menu still + // shows the stale contents and saving them would resurrect the kit. + skipNextSave.add(player.getUniqueId()); + menu.close(player); + SoundManager.playCloseGui(player); + } + + private void saveKitRoomPage(Player player, GuiContext context) { + List dataSlots = componentSlots(KIT_ROOM, "kit-room-data"); + if (dataSlots.isEmpty()) { + return; + } + + Inventory top = player.getOpenInventory().getTopInventory(); + ItemStack[] data = new ItemStack[dataSlots.size()]; + for (int i = 0; i < dataSlots.size(); i++) { + ItemStack item = top.getItem(dataSlots.get(i)); + data[i] = item == null ? null : item.clone(); + } + + KitRoomDataManager.get().setKitRoom(context.getInt("page_index", 0), data); + KitRoomDataManager.get().saveToDBAsync(); + Lang.get().send(player, "success.kitroom-menu-saved"); + SoundManager.playSuccess(player); + } + + private void clearEditorSlots(Player player, Menu menu, Map action) { + for (int slotIndex : editorActionSlots(player, menu, action)) { + menu.getSlot(slotIndex).setItem((ItemStack) null); + } + } + + private void importIntoEditor(Player player, Menu menu, Map action, ItemStack[] source) { + List slots = editorActionSlots(player, menu, action); + for (int i = 0; i < slots.size(); i++) { + ItemStack item = i < source.length && source[i] != null ? source[i].clone() : null; + menu.getSlot(slots.get(i)).setItem(item); + } + } + + /** + * The slots an editor action operates on: the open session's data slots, + * or an explicit "slots" override on the action. + */ + private List editorActionSlots(Player player, Menu menu, Map action) { + Object override = action.get("slots"); + if (override != null) { + return parseSlots(override, menu.getDimensions().getArea()); + } + EditorSession session = editorSessions.get(player.getUniqueId()); + return session != null ? session.dataSlots() : Collections.emptyList(); + } + + private ItemStack[] filteredContents(ItemStack[] contents) { + return filterItemsOnImport ? ItemFilter.get().filterItemStack(contents) : contents; + } + + private void closeAfterAction(Player player, Menu menu, Map action) { + if (booleanValue(action, "close")) { + menu.close(player); + } + } + + // ------------------------------------------------------------------ + // Data resolution + // ------------------------------------------------------------------ + + private ItemStack[] resolveKitData(Player viewer, GuiContext context) { + String publicKitId = context.getString("id"); + if (publicKitId != null && !publicKitId.isEmpty()) { + return KitManager.get().getItemStackArrayById(IDUtil.getPublicKitId(publicKitId)); + } + + UUID owner = context.getUuid("target_uuid"); + return KitManager.get().getItemStackArrayById( + IDUtil.getPlayerKitId(owner != null ? owner : viewer.getUniqueId(), context.getInt("slot", 1))); + } + + private ItemStack[] resolveEnderchestData(Player viewer, GuiContext context) { + UUID owner = context.getUuid("target_uuid"); + return KitManager.get().getItemStackArrayById( + IDUtil.getECId(owner != null ? owner : viewer.getUniqueId(), context.getInt("slot", 1))); + } + + private GuiContext enrichPublicKitContext(GuiContext context, String publicKitId) { + for (PublicKit publicKit : KitManager.get().getPublicKitList()) { + if (publicKit.id.equals(publicKitId)) { + return context + .with("public_kit_name", publicKit.name) + .with("public_kit_icon", publicKit.icon.name()); + } + } + return context; + } + + // ------------------------------------------------------------------ + // Items and text + // ------------------------------------------------------------------ + + private ItemStack buildItem(ConfigurationSection itemSection, Player viewer, GuiContext context) { + if (itemSection == null) { + return null; + } + + String materialSpec = itemSection.getString("material"); + if (materialSpec == null || materialSpec.isEmpty()) { + return null; + } + + boolean styledGlass = "@glass".equalsIgnoreCase(materialSpec); + if (styledGlass && !itemSection.isString("name")) { + return ItemUtil.createGlassPane(); + } + + Material material; + if (styledGlass) { + material = StyleManager.get().getGlassMaterial(); + } else { + String materialName = resolveText(materialSpec, viewer, context); + material = Material.matchMaterial(materialName); + if (material == null) { + plugin.getLogger().warning("Unknown material '" + materialName + "' in " + itemSection.getCurrentPath()); + return null; + } + } + + Integer amountValue = GuiText.resolveInt(itemSection.get("amount"), key -> placeholderValue(key, viewer, context)); + int amount = amountValue != null ? Math.max(1, amountValue) : 1; + String name = resolveText(itemSection.getString("name"), viewer, context); + + List loreLines = itemSection.getStringList("lore"); + String[] lore = new String[loreLines.size()]; + for (int i = 0; i < loreLines.size(); i++) { + lore[i] = resolveText(loreLines.get(i), viewer, context); + } + + ItemStack item = ItemUtil.createItem(material, amount, name, lore); + if (itemSection.getBoolean("hide-flags", false)) { + ItemUtil.addHideFlags(item); + } + if (itemSection.getBoolean("glow", false)) { + ItemUtil.addEnchantLook(item); + } + return item; + } + + /** + * Expands %lang:key% (with context values applied as the lang message's + * {token} placeholders) and %token%, then PlaceholderAPI when installed. + * MiniMessage conversion is left to the consumer (ItemUtil for items, + * resolveTitle for titles). + */ + private String resolveText(String value, Player viewer, GuiContext context) { + if (value == null) { + return null; + } + + String resolved = GuiText.resolve(value, + key -> Lang.get().raw(key, context.stringValues()), + key -> placeholderValue(key, viewer, context)); + // Only unknown %tokens% remain; skip the PAPI scan when there are none. + if (resolved.indexOf('%') >= 0 && Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { + resolved = applyPlaceholderApi(viewer, resolved); + } + return resolved; + } + + /** Separate method so the optional PlaceholderAPI class loads lazily. */ + private String applyPlaceholderApi(Player viewer, String text) { + return PlaceholderAPI.setPlaceholders(viewer, text); + } + + private String placeholderValue(String key, Player viewer, GuiContext context) { + Object contextValue = context.get(key); + if (contextValue != null) { + return String.valueOf(contextValue); + } + + return switch (key) { + case "viewer_name" -> viewer.getName(); + case "viewer_uuid" -> viewer.getUniqueId().toString(); + case "primary_color" -> StyleManager.get().getPrimaryColorTag(); + default -> null; + }; + } + + // ------------------------------------------------------------------ + // Config access helpers + // ------------------------------------------------------------------ + + private List parseSlots(Object rawValue, int menuSize) { + return GuiSlots.parse(rawValue, menuSize, message -> plugin.getLogger().warning(message + " in guis.yml")); + } + + private List componentSlots(String guiId, String componentName) { + return componentSlotsCache.computeIfAbsent(guiId + ":" + componentName, key -> { + ConfigurationSection guiSection = guiConfig.getGuiSection(guiId); + if (guiSection == null) { + return Collections.emptyList(); + } + int menuSize = rows(guiSection) * 9; + for (ConfigurationSection elementSection : orderedElements(guiSection)) { + if ("component".equalsIgnoreCase(elementSection.getString("type", "")) + && componentName.equalsIgnoreCase(elementSection.getString("component", ""))) { + return List.copyOf(parseSlots(elementSection.get("slots"), menuSize)); + } + } + return Collections.emptyList(); + }); + } + + /** + * Builds the context an open-gui action passes along. Only explicitly + * declared keys are forwarded — the current menu's context does not leak + * into the next one. String values resolve %token% placeholders against + * the current context; a value that is exactly one token ("%slot%") + * passes the original object through so numbers stay numbers. + */ + private GuiContext actionContext(GuiContext currentContext, Object rawContext) { + GuiContext nextContext = GuiContext.empty(); + if (!(rawContext instanceof Map rawContextMap)) { + return nextContext; + } + + for (Map.Entry entry : rawContextMap.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) { + continue; + } + + String key = String.valueOf(entry.getKey()); + Object value = entry.getValue(); + if (value instanceof String stringValue) { + Object passthrough = contextPassthrough(stringValue, currentContext); + value = passthrough != null ? passthrough + : GuiText.resolve(stringValue, k -> Lang.get().raw(k, currentContext.stringValues()), currentContext::getString); + } + nextContext = nextContext.with(key, value); + } + return nextContext; + } + + private Object contextPassthrough(String value, GuiContext context) { + if (value.length() > 2 && value.startsWith("%") && value.endsWith("%") + && value.indexOf('%', 1) == value.length() - 1) { + return context.get(value.substring(1, value.length() - 1)); + } + return null; + } + + private String actionString(Map action, String key, GuiContext context, String fallback) { + String rawValue = stringValue(action, key); + if (rawValue == null || rawValue.isEmpty()) { + return fallback; + } + Object passthrough = contextPassthrough(rawValue, context); + if (passthrough != null) { + return String.valueOf(passthrough); + } + return GuiText.resolve(rawValue, k -> Lang.get().raw(k, context.stringValues()), context::getString); + } + + /** + * An action's int argument: the explicit value (with %token% placeholders + * resolved), or the context's value under the same key. + */ + private int actionInt(Map action, String key, GuiContext context) { + Object rawValue = action.get(key); + if (rawValue == null) { + return context.getInt(key, 1); + } + Integer resolved = GuiText.resolveInt(rawValue, context::getString); + if (resolved == null) { + int fallback = context.getInt(key, 1); + plugin.getLogger().warning("Could not resolve '" + key + "' value '" + rawValue + "' in guis.yml action, using " + fallback); + return fallback; + } + return resolved; + } + + private String stringValue(Map map, String key) { + Object value = map.get(key); + return value == null ? null : String.valueOf(value); + } + + private boolean booleanValue(Map map, String key) { + Object value = map.get(key); + if (value instanceof Boolean bool) { + return bool; + } + return value instanceof String stringValue && Boolean.parseBoolean(stringValue); + } +} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/EditorSession.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/EditorSession.java new file mode 100644 index 0000000..27c2a11 --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/EditorSession.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.UUID; + +/** + * Tracks an open kit/enderchest editor so its contents can be persisted when + * the menu goes away — whether by a real InventoryCloseEvent or by navigating + * to another menu that reuses the open inventory (canvas redraw), where no + * close event ever fires. + * + * @param dataSlots menu slots holding kit data, in kit-index order, resolved + * from guis.yml when the editor was opened + * @param menuSize expected top-inventory size, to avoid saving from an + * inventory that is not this editor + */ +public record EditorSession(EditorType type, int slot, String publicKitId, UUID target, String targetName, + List dataSlots, int menuSize) { + + public enum EditorType { + KIT, + PUBLIC_KIT, + ENDERCHEST, + INSPECT_KIT, + INSPECT_ENDERCHEST + } + + public boolean matches(Inventory inventory) { + return inventory.getSize() == menuSize && inventory.getLocation() == null; + } + + /** + * Reads the data slots out of the closing inventory, cloned. The result + * is at least {@code minSize} long because KitManager's save methods index + * fixed positions (armor at 36-39) even when a customized layout exposes + * fewer slots. + */ + public ItemStack[] extractContents(Inventory inventory, int minSize) { + ItemStack[] contents = new ItemStack[Math.max(dataSlots.size(), minSize)]; + for (int i = 0; i < dataSlots.size(); i++) { + ItemStack item = inventory.getItem(dataSlots.get(i)); + contents[i] = item == null ? null : item.clone(); + } + return contents; + } +} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiConfigManager.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiConfigManager.java new file mode 100644 index 0000000..60b7c6d --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiConfigManager.java @@ -0,0 +1,97 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; + +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Loads guis.yml. GUIs missing from the user's file fall back to the copy + * bundled in the jar, so upgrades that ship new menus keep working on + * installs whose file predates them. A GUI the user's file does define is + * used as-is — per-GUI, not per-key, so a customized menu is never a mix of + * user and default elements. + */ +public class GuiConfigManager { + + private final Plugin plugin; + private final File guiConfigFile; + private FileConfiguration userConfig = new YamlConfiguration(); + private FileConfiguration bundledConfig = new YamlConfiguration(); + + public GuiConfigManager(Plugin plugin) { + this.plugin = plugin; + this.guiConfigFile = new File(plugin.getDataFolder(), "guis.yml"); + load(); + } + + private void load() { + if (!guiConfigFile.exists()) { + plugin.saveResource("guis.yml", false); + } + userConfig = YamlConfiguration.loadConfiguration(guiConfigFile); + + InputStream bundled = plugin.getResource("guis.yml"); + if (bundled != null) { + bundledConfig = YamlConfiguration.loadConfiguration(new InputStreamReader(bundled, StandardCharsets.UTF_8)); + } + + int userVersion = userConfig.getInt("config-version", 0); + int bundledVersion = bundledConfig.getInt("config-version", 0); + if (userVersion < bundledVersion) { + migrate(userVersion); + } + } + + /** + * A schema bump can rename components, actions, or keys, and a stale file + * shadows the bundled menus wholesale (per-GUI fallback) — so archive it + * and regenerate rather than run menus that half-work. The archived copy + * keeps the owner's customizations for manual re-application. + */ + private void migrate(int userVersion) { + File archived = new File(plugin.getDataFolder(), "guis.yml.v" + userVersion + ".bak"); + if (guiConfigFile.renameTo(archived)) { + plugin.saveResource("guis.yml", false); + userConfig = YamlConfiguration.loadConfiguration(guiConfigFile); + plugin.getLogger().warning("guis.yml used an older layout schema (config-version " + userVersion + + "); it was archived to " + archived.getName() + " and regenerated." + + " Re-apply any customizations from the archived file."); + } else { + plugin.getLogger().warning("guis.yml uses an older layout schema (config-version " + userVersion + + ") and could not be archived; menus it defines may misbehave." + + " Delete the file to regenerate it."); + } + } + + public ConfigurationSection getGuiSection(String guiId) { + ConfigurationSection section = userConfig.getConfigurationSection("guis." + guiId); + if (section != null) { + return section; + } + return bundledConfig.getConfigurationSection("guis." + guiId); + } +} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiContext.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiContext.java new file mode 100644 index 0000000..95a7640 --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiContext.java @@ -0,0 +1,113 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import com.google.common.primitives.Ints; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Immutable bag of values a GUI is opened with (kit slot, inspect target, + * page, ...). Placeholders in guis.yml resolve against these, and actions + * read their arguments from them. + */ +public final class GuiContext { + + private static final GuiContext EMPTY = new GuiContext(Collections.emptyMap()); + + private final Map values; + // Lazily built, idempotent; volatile so a reader on another thread never + // sees a partially constructed map. Everything else here is immutable. + private volatile Map stringValues; + + private GuiContext(Map values) { + this.values = Collections.unmodifiableMap(values); + } + + public static GuiContext empty() { + return EMPTY; + } + + public GuiContext with(String key, Object value) { + if (key == null || value == null) { + return this; + } + + Map updated = new LinkedHashMap<>(values); + updated.put(key, value); + return new GuiContext(updated); + } + + public Object get(String key) { + return values.get(key); + } + + public boolean has(String key) { + return values.containsKey(key); + } + + public String getString(String key) { + Object value = values.get(key); + return value == null ? null : String.valueOf(value); + } + + /** All values stringified, for Lang's {token} substitution. Memoized. */ + public Map stringValues() { + if (stringValues == null) { + Map converted = new LinkedHashMap<>(values.size()); + values.forEach((key, value) -> converted.put(key, String.valueOf(value))); + stringValues = Collections.unmodifiableMap(converted); + } + return stringValues; + } + + public Integer getInt(String key) { + Object value = values.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String stringValue) { + return Ints.tryParse(stringValue.trim()); + } + return null; + } + + public int getInt(String key, int defaultValue) { + Integer value = getInt(key); + return value != null ? value : defaultValue; + } + + public UUID getUuid(String key) { + Object value = values.get(key); + if (value instanceof UUID uuid) { + return uuid; + } + if (value instanceof String stringValue) { + try { + return UUID.fromString(stringValue); + } catch (IllegalArgumentException e) { + return null; + } + } + return null; + } +} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java new file mode 100644 index 0000000..b42ccc1 --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java @@ -0,0 +1,111 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import com.google.common.primitives.Ints; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.function.Consumer; + +/** + * Parses slot declarations from guis.yml. A declaration may be a single + * number, a string of comma-separated entries and ranges ("0-8, 17, 26-18"), + * or a YAML list mixing both. Order is preserved and duplicates dropped, + * because item-data components map data indexes onto slots positionally. + */ +public final class GuiSlots { + + private static final int MAX_RANGE_SIZE = 128; + + private GuiSlots() { + } + + public static List parse(Object rawValue, int menuSize, Consumer warn) { + if (rawValue == null) { + return Collections.emptyList(); + } + + LinkedHashSet slots = new LinkedHashSet<>(); + if (rawValue instanceof Number number) { + slots.add(number.intValue()); + } else if (rawValue instanceof String stringValue) { + parseString(stringValue, slots, warn); + } else if (rawValue instanceof List list) { + for (Object entry : list) { + if (entry instanceof Number number) { + slots.add(number.intValue()); + } else if (entry instanceof String stringValue) { + parseString(stringValue, slots, warn); + } + } + } else { + warn.accept("Unsupported slot declaration '" + rawValue + "'"); + } + + List result = new ArrayList<>(slots.size()); + for (int slot : slots) { + if (slot < 0 || slot >= menuSize) { + warn.accept("Slot " + slot + " is outside the menu (size " + menuSize + "), skipping"); + continue; + } + result.add(slot); + } + return result; + } + + private static void parseString(String value, LinkedHashSet slots, Consumer warn) { + for (String part : value.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + + int dash = trimmed.indexOf('-', trimmed.startsWith("-") ? 1 : 0); + if (dash > 0) { + Integer start = Ints.tryParse(trimmed.substring(0, dash).trim()); + Integer end = Ints.tryParse(trimmed.substring(dash + 1).trim()); + if (start == null || end == null) { + warn.accept("Invalid slot range '" + trimmed + "'"); + continue; + } + // No menu has more than 54 slots; a huge range is a typo, and + // expanding it first would balloon the set (or overflow at the + // int boundary) before the bounds check could reject it. + if (Math.abs((long) end - start) >= MAX_RANGE_SIZE) { + warn.accept("Slot range '" + trimmed + "' is far larger than any menu, skipping"); + continue; + } + int step = start <= end ? 1 : -1; + for (int slot = start; slot != end + step; slot += step) { + slots.add(slot); + } + } else { + Integer slot = Ints.tryParse(trimmed); + if (slot == null) { + warn.accept("Invalid slot entry '" + trimmed + "'"); + continue; + } + slots.add(slot); + } + } + } +} diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiText.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiText.java new file mode 100644 index 0000000..0ef07af --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiText.java @@ -0,0 +1,93 @@ +/* + * Copyright 2022-2025 Noah Ross + * + * This file is part of PerPlayerKit. + * + * PerPlayerKit is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for + * more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with PerPlayerKit. If not, see . + */ +package dev.noah.perplayerkit.gui.configurable; + +import com.google.common.primitives.Ints; + +import java.util.function.Function; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Placeholder resolution for guis.yml text, kept free of Bukkit types so it + * can be unit tested. Two placeholder forms are expanded, in order: + * + *

    + *
  1. {@code %lang:%} — a message from the active language file. The + * lang lookup is expected to substitute the message's own {@code {token}} + * placeholders (Lang does this) before returning.
  2. + *
  3. {@code %token%} — a GUI context value. Unknown tokens are left + * untouched so PlaceholderAPI can claim them.
  4. + *
+ */ +public final class GuiText { + + private static final Pattern LANG_PATTERN = Pattern.compile("%lang:([a-zA-Z0-9_.-]+)%"); + private static final Pattern PERCENT_PATTERN = Pattern.compile("%([a-zA-Z0-9_]+)%"); + + private GuiText() { + } + + /** + * @param value raw text from guis.yml + * @param lang resolves a language key to its message, context tokens + * already applied + * @param values resolves a context token, or null when unknown + */ + public static String resolve(String value, UnaryOperator lang, Function values) { + if (value == null) { + return null; + } + + String result = replace(value, LANG_PATTERN, lang); + return replace(result, PERCENT_PATTERN, values); + } + + /** + * Resolves a config value to an int, expanding placeholders in string + * values first so "%slot%" means the context's slot rather than silently + * falling back to a default. Returns null when the value is missing or + * not numeric after expansion. + */ + public static Integer resolveInt(Object rawValue, Function values) { + if (rawValue instanceof Number number) { + return number.intValue(); + } + if (rawValue instanceof String stringValue) { + return Ints.tryParse(replace(stringValue, PERCENT_PATTERN, values).trim()); + } + return null; + } + + private static String replace(String value, Pattern pattern, Function lookup) { + Matcher matcher = pattern.matcher(value); + if (!matcher.find()) { + return value; + } + + StringBuilder buffer = new StringBuilder(value.length()); + do { + String replacement = lookup.apply(matcher.group(1)); + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement != null ? replacement : matcher.group())); + } while (matcher.find()); + matcher.appendTail(buffer); + return buffer.toString(); + } +} diff --git a/src/main/java/dev/noah/perplayerkit/listeners/KitMenuCloseListener.java b/src/main/java/dev/noah/perplayerkit/listeners/KitMenuCloseListener.java index fa8bf83..c0d97e5 100644 --- a/src/main/java/dev/noah/perplayerkit/listeners/KitMenuCloseListener.java +++ b/src/main/java/dev/noah/perplayerkit/listeners/KitMenuCloseListener.java @@ -18,29 +18,25 @@ */ package dev.noah.perplayerkit.listeners; -import dev.noah.perplayerkit.gui.EditorSaver; -import dev.noah.perplayerkit.gui.GUI; +import dev.noah.perplayerkit.gui.configurable.ConfigurableGuiService; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.inventory.Inventory; +/** + * Persists kit/enderchest editor contents when the menu inventory actually + * closes. Navigation between menus that reuses the open inventory never fires + * this event; ConfigurableGuiService flushes the editor session itself before + * opening the next menu. + */ public class KitMenuCloseListener implements Listener { @EventHandler public void onEditorClose(InventoryCloseEvent e) { - Inventory inv = e.getInventory(); - if (inv.getSize() != 54 || inv.getLocation() != null) { + if (!(e.getPlayer() instanceof Player player)) { return; } - - Player player = (Player) e.getPlayer(); - GUI.EditorContext context = GUI.getAndRemoveEditorContext(player.getUniqueId()); - if (context == null) { - return; - } - - EditorSaver.save(player, context, inv); + ConfigurableGuiService.get().handleInventoryClose(player, e.getInventory()); } } diff --git a/src/main/resources/guis.yml b/src/main/resources/guis.yml new file mode 100644 index 0000000..e3d0ec3 --- /dev/null +++ b/src/main/resources/guis.yml @@ -0,0 +1,768 @@ +# ----------------------------------------------------------------------------- +# PerPlayerKit menu layouts +# ----------------------------------------------------------------------------- +# Every menu the plugin opens is described here. A menu this file does not +# define falls back to the copy bundled inside the jar, so deleting a section +# (or running an older file after an update) never breaks the plugin. +# +# Menu keys: +# rows 1-6 chest rows (default 6) +# title inventory title; "title-paged" is used instead when the main +# menu spans multiple pages (max-kits > 9) +# open-sound play the open-GUI sound when this menu opens +# elements the menu's contents, rendered in ascending "order" +# +# Element keys: +# type "static" (an item, optionally clickable) or "component" +# (plugin-rendered content, see below); "fill" is an +# alias of static +# slots where to render: a number, "0-8,53" style string, or list +# order render order; higher paints over lower (fill first, +# data next, buttons last) +# item material / amount / name / lore / glow / hide-flags. +# material "@glass" is the styled filler pane. +# permission only render for viewers with this permission +# exclude-permission only render for viewers WITHOUT this permission +# editable true/false, or a permission node that grants editing +# actions click handlers, keyed by click type: a specific type +# ("shift_right"), "shift", "left", "right", "middle", +# or "any". The most specific matching key wins. +# variants per-state item/actions overrides used by components +# (e.g. an enderchest slot renders "exists" or +# "missing"; a missing variant falls back to "default", +# then to the element itself) +# +# Components: +# kit-slot-selector one button per kit slot of the current page; variants +# "exists"/"missing". "source: enderchest" checks +# enderchest slots instead of kits. +# main-menu-pagination previous/next page arrows ("previous-slot"/"next-slot") +# public-kit-list one entry per configured public kit; variants +# assigned/unassigned/admin_assigned/admin_unassigned +# kit-data / enderchest-data / player-enderchest-data / kit-room-data +# item contents; the editor save uses these slots, in +# order, so keep them contiguous unless you know better +# kit-room-category-buttons one button per kitroom category (config.yml) +# +# Text placeholders, resolved in this order: +# %lang:% a message from the active language file (lang/.yml) +# %token% a context value: slot, page (1-based), page_index (0-based), +# pages, player, id, public_kit_name, public_kit_icon, +# kitroom_name, kitroom_material, viewer_name, viewer_uuid, +# primary_color +# PlaceholderAPI placeholders work anywhere when the plugin is installed. +# Text is MiniMessage ("..."). +# +# Actions: +# open-gui {gui, context: {...}} — open another menu. Only the +# keys you declare are passed; use "%token%" values +# to forward from the current context. +# load-player-kit / load-enderchest / load-public-kit {close: true} +# close close the menu +# clear-editor empty the editor's data slots +# import-player-inventory / import-player-enderchest fill the editor +# clear-player-inventory / repair-player-items +# delete-player-kit / delete-player-enderchest (inspect menus) +# save-kit-room-page / broadcast-kit-room-opened +# Any action may carry "permission" to restrict who can trigger it. +# +# The "templates" section holds YAML anchors shared by several menus below. +# To restyle one menu's copy independently, replace its "<<:" merge line with +# the elements written out. +# ----------------------------------------------------------------------------- + +config-version: 2 + +templates: + armor-indicators: &armor-indicators + boots: + order: 20 + type: static + slots: 45 + item: + material: CHAINMAIL_BOOTS + name: "BOOTS" + leggings: + order: 21 + type: static + slots: 46 + item: + material: CHAINMAIL_LEGGINGS + name: "LEGGINGS" + chestplate: + order: 22 + type: static + slots: 47 + item: + material: CHAINMAIL_CHESTPLATE + name: "CHESTPLATE" + helmet: + order: 23 + type: static + slots: 48 + item: + material: CHAINMAIL_HELMET + name: "HELMET" + offhand: + order: 24 + type: static + slots: 49 + item: + material: SHIELD + name: "OFFHAND" + kit-editor-tools: &kit-editor-tools + import: + order: 30 + type: static + slots: 51 + item: + material: CHEST + name: "%lang:gui.import-button%" + lore: + - "%lang:gui.lore-import-inventory%" + actions: + any: + - type: import-player-inventory + clear: + order: 31 + type: static + slots: 52 + item: + material: BARRIER + name: "%lang:gui.clear-kit-button%" + lore: + - "%lang:gui.lore-shift-clear%" + actions: + shift: + - type: clear-editor + +guis: + main-menu: + rows: 6 + title: "%primary_color%%lang:gui.main-menu-title%" + title-paged: "%primary_color%%lang:gui.main-menu-title-paged%" + elements: + fill: + order: 0 + type: fill + slots: "0-53" + item: + material: "@glass" + kits: + order: 10 + type: component + component: kit-slot-selector + slots: "9-17" + item: + material: CHEST + name: "%lang:gui.kit-slot-name%" + lore: + - "%lang:gui.lore-left-load%" + - "%lang:gui.lore-right-edit%" + actions: + left: + - type: load-player-kit + close: true + right: + - type: open-gui + gui: player-kit-editor + context: + slot: "%slot%" + enderchests: + order: 20 + type: component + component: kit-slot-selector + source: enderchest + slots: "18-26" + variants: + exists: + item: + material: ENDER_CHEST + name: "%lang:gui.enderchest-slot-name%" + lore: + - "%lang:gui.lore-left-load%" + - "%lang:gui.lore-right-edit%" + actions: + left: + - type: load-enderchest + close: true + right: + - type: open-gui + gui: enderchest-editor + context: + slot: "%slot%" + missing: + item: + material: ENDER_EYE + name: "%lang:gui.enderchest-slot-name%" + lore: + - "%lang:gui.lore-click-create%" + actions: + any: + - type: open-gui + gui: enderchest-editor + context: + slot: "%slot%" + kit-status: + order: 30 + type: component + component: kit-slot-selector + slots: "27-35" + variants: + exists: + item: + material: KNOWLEDGE_BOOK + name: "%lang:gui.kit-exists%" + lore: + - "%lang:gui.lore-click-edit%" + actions: + any: + - type: open-gui + gui: player-kit-editor + context: + slot: "%slot%" + missing: + item: + material: BOOK + name: "%lang:gui.kit-not-found%" + lore: + - "%lang:gui.lore-click-create%" + actions: + any: + - type: open-gui + gui: player-kit-editor + context: + slot: "%slot%" + kit-room: + order: 40 + type: static + slots: 37 + item: + material: NETHER_STAR + name: "%lang:gui.kit-room-button%" + actions: + any: + - type: open-gui + gui: kit-room + - type: broadcast-kit-room-opened + public-kits: + order: 41 + type: static + slots: 38 + item: + material: BOOKSHELF + name: "%lang:gui.premade-kits-button%" + actions: + any: + - type: open-gui + gui: public-kit-menu + info: + order: 42 + type: static + slots: 39 + item: + material: OAK_SIGN + name: "%lang:gui.info-button%" + lore: + - "%lang:gui.lore-info-load%" + - "%lang:gui.lore-info-edit%" + - "%lang:gui.lore-info-share%" + clear-inventory: + order: 43 + type: static + slots: 41 + item: + material: REDSTONE_BLOCK + name: "%lang:gui.clear-inventory-button%" + lore: + - "%lang:gui.lore-shift-click%" + actions: + shift: + - type: clear-player-inventory + share-info: + order: 44 + type: static + slots: 42 + item: + material: COMPASS + name: "%lang:gui.share-kits-button%" + lore: + - "%lang:gui.lore-share-kits%" + repair: + order: 45 + type: static + slots: 43 + item: + material: EXPERIENCE_BOTTLE + name: "%lang:gui.repair-items-button%" + actions: + any: + - type: repair-player-items + pagination: + order: 50 + type: component + component: main-menu-pagination + previous-slot: 45 + next-slot: 53 + variants: + previous: + item: + material: ARROW + name: "%lang:gui.previous-page-button%" + lore: + - "%lang:gui.lore-page-indicator%" + actions: + any: + - type: open-gui + gui: main-menu + context: + page_index: "%page_index%" + next: + item: + material: ARROW + name: "%lang:gui.next-page-button%" + lore: + - "%lang:gui.lore-page-indicator%" + actions: + any: + - type: open-gui + gui: main-menu + context: + page_index: "%page_index%" + + player-kit-editor: + rows: 6 + title: "%primary_color%%lang:gui.kit-editor-title%" + elements: + <<: [*armor-indicators, *kit-editor-tools] + fill: + order: 0 + type: fill + slots: "41-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: kit-data + slots: "0-40" + editable: true + back: + order: 32 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: main-menu + context: + page_index: "%slot_page%" + + public-kit-editor: + rows: 6 + title: "%primary_color%%lang:gui.public-kit-editor-title%" + elements: + <<: [*armor-indicators, *kit-editor-tools] + fill: + order: 0 + type: fill + slots: "41-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: kit-data + slots: "0-40" + editable: true + back: + order: 32 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: main-menu + + enderchest-editor: + rows: 6 + title: "%primary_color%%lang:gui.enderchest-editor-title%" + elements: + fill: + order: 0 + type: fill + slots: "0-8,36-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: enderchest-data + slots: "9-35" + editable: true + import: + order: 30 + type: static + slots: 51 + item: + material: ENDER_CHEST + name: "%lang:gui.import-button%" + lore: + - "%lang:gui.lore-import-ec%" + actions: + any: + - type: import-player-enderchest + clear: + order: 31 + type: static + slots: 52 + item: + material: BARRIER + name: "%lang:gui.clear-ec-button%" + lore: + - "%lang:gui.lore-shift-clear%" + actions: + shift: + - type: clear-editor + back: + order: 32 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: main-menu + context: + page_index: "%slot_page%" + + inspect-kit: + rows: 6 + title: "%primary_color%%lang:gui.inspect-kit-title%" + open-sound: true + elements: + <<: *armor-indicators + fill: + order: 0 + type: fill + slots: "41-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: kit-data + slots: "0-40" + editable: perplayerkit.admin + delete: + order: 30 + type: static + permission: perplayerkit.admin + slots: 52 + item: + material: BARRIER + name: "%lang:gui.clear-kit-button%" + lore: + - "%lang:gui.lore-shift-delete-kit%" + actions: + shift: + - type: delete-player-kit + close: + order: 31 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.close-button%" + actions: + any: + - type: close + + inspect-enderchest: + rows: 6 + title: "%primary_color%%lang:gui.inspect-ec-title%" + open-sound: true + elements: + fill: + order: 0 + type: fill + slots: "0-8,36-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: enderchest-data + slots: "9-35" + editable: perplayerkit.admin + delete: + order: 30 + type: static + permission: perplayerkit.admin + slots: 52 + item: + material: BARRIER + name: "%lang:gui.clear-ec-button%" + lore: + - "%lang:gui.lore-shift-delete-ec%" + actions: + shift: + - type: delete-player-enderchest + close: + order: 31 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.close-button%" + actions: + any: + - type: close + + kit-room: + rows: 6 + title: "%primary_color%%lang:gui.kit-room-title%" + elements: + fill: + order: 0 + type: fill + slots: "45-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: kit-room-data + slots: "0-44" + editable: true + refill: + order: 20 + type: static + slots: 45 + item: + material: BEACON + name: "%lang:gui.refill-button%" + actions: + any: + - type: open-gui + gui: kit-room + context: + page_index: "%page_index%" + categories: + order: 30 + type: component + component: kit-room-category-buttons + slots: "47-51" + variants: + default: + item: + material: "%kitroom_material%" + name: "%kitroom_name%" + hide-flags: true + actions: + any: + - type: open-gui + gui: kit-room + context: + page_index: "%page_index%" + active: + item: + material: "%kitroom_material%" + name: "%kitroom_name%" + hide-flags: true + glow: true + actions: + any: + - type: open-gui + gui: kit-room + context: + page_index: "%page_index%" + back: + order: 40 + type: static + exclude-permission: perplayerkit.editkitroom + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: main-menu + save: + order: 41 + type: static + permission: perplayerkit.editkitroom + slots: 53 + item: + material: BARRIER + # Stack size shows which category page a save would overwrite. + amount: "%page%" + name: "%lang:gui.edit-menu-button%" + lore: + - "%lang:gui.edit-menu-lore%" + actions: + shift_right: + - type: save-kit-room-page + permission: perplayerkit.editkitroom + + public-kit-menu: + rows: 6 + title: "%primary_color%%lang:gui.public-kit-room-title%" + elements: + fill: + order: 0 + type: fill + slots: "0-53" + item: + material: "@glass" + coming-soon: + order: 5 + type: static + slots: "9-35" + item: + material: BOOK + name: "%lang:gui.more-kits-coming%" + kits: + order: 10 + type: component + component: public-kit-list + # 27 slots; kits beyond that are logged and not shown. Widen the + # range (and coming-soon above) if you define more. + slots: "9-35" + variants: + assigned: + item: + material: "%public_kit_icon%" + name: "%public_kit_name%" + actions: + left: + - type: load-public-kit + close: true + right: + - type: open-gui + gui: public-kit-viewer + context: + id: "%id%" + unassigned: + item: + material: "%public_kit_icon%" + name: "%public_kit_name% %lang:gui.unassigned-tag%" + lore: + - "%lang:gui.lore-unassigned-info%" + admin_assigned: + item: + material: "%public_kit_icon%" + name: "%public_kit_name%" + lore: + - "%lang:gui.lore-admin-shift-edit%" + actions: + shift: + - type: open-gui + gui: public-kit-editor + context: + id: "%id%" + # No close on load: admins test several kits in one session. + left: + - type: load-public-kit + right: + - type: open-gui + gui: public-kit-viewer + context: + id: "%id%" + admin_unassigned: + item: + material: "%public_kit_icon%" + name: "%public_kit_name% %lang:gui.unassigned-tag%" + lore: + - "%lang:gui.lore-unassigned-info%" + - "%lang:gui.lore-admin-shift-edit%" + actions: + shift: + - type: open-gui + gui: public-kit-editor + context: + id: "%id%" + # Both give the "kit not found / how to assign" feedback. + left: + - type: load-public-kit + right: + - type: open-gui + gui: public-kit-viewer + context: + id: "%id%" + back: + order: 20 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: main-menu + + public-kit-viewer: + rows: 6 + title: "%primary_color%%lang:gui.view-public-kit-title%" + elements: + <<: *armor-indicators + fill: + order: 0 + type: fill + slots: "41-53" + item: + material: "@glass" + data: + order: 10 + type: component + component: kit-data + slots: "0-40" + load: + order: 30 + type: static + slots: 52 + item: + material: APPLE + name: "%lang:gui.load-kit-button%" + actions: + any: + - type: load-public-kit + close: true + back: + order: 31 + type: static + slots: 53 + item: + material: OAK_DOOR + name: "%lang:gui.back-button%" + actions: + any: + - type: open-gui + gui: public-kit-menu + + view-only-enderchest: + rows: 5 + title: "%primary_color%%lang:gui.enderchest-view-title%" + open-sound: true + elements: + fill: + order: 0 + type: fill + slots: "0-8,36-44" + item: + material: "@glass" + data: + order: 10 + type: component + component: player-enderchest-data + slots: "9-35" diff --git a/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java b/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java new file mode 100644 index 0000000..b5cfe24 --- /dev/null +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java @@ -0,0 +1,149 @@ +package dev.noah.perplayerkit.gui.configurable; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sanity checks on the guis.yml shipped in the jar: every menu the service + * opens by id must exist, editor data components must expose exactly the + * slot counts KitManager's save methods expect, and the YAML template + * anchors must survive a YamlConfiguration (SnakeYAML) load — the same + * library the server uses. + */ +class BundledGuisConfigTest { + + private static YamlConfiguration config; + + @BeforeAll + static void load() throws Exception { + try (var stream = BundledGuisConfigTest.class.getClassLoader().getResourceAsStream("guis.yml")) { + assertNotNull(stream, "guis.yml missing from resources"); + config = YamlConfiguration.loadConfiguration(new InputStreamReader(stream, StandardCharsets.UTF_8)); + } + } + + private static ConfigurationSection gui(String id) { + ConfigurationSection section = config.getConfigurationSection("guis." + id); + assertNotNull(section, "guis.yml is missing the '" + id + "' menu"); + return section; + } + + private static ConfigurationSection element(String guiId, String elementKey) { + ConfigurationSection section = gui(guiId).getConfigurationSection("elements." + elementKey); + assertNotNull(section, guiId + " is missing element '" + elementKey + "'"); + return section; + } + + private static List elementSlots(String guiId, String elementKey) { + int menuSize = gui(guiId).getInt("rows", 6) * 9; + return GuiSlots.parse(element(guiId, elementKey).get("slots"), menuSize, + message -> { + throw new AssertionError(message); + }); + } + + @Test + void hasConfigVersion() { + assertTrue(config.getInt("config-version", 0) >= 1); + } + + @Test + void definesEveryMenuTheServiceOpens() { + for (String id : List.of("main-menu", "player-kit-editor", "public-kit-editor", "enderchest-editor", + "inspect-kit", "inspect-enderchest", "kit-room", "public-kit-menu", "public-kit-viewer", + "view-only-enderchest")) { + gui(id); + } + } + + @Test + void kitEditorsExposeFullKitContents() { + // 41 = 36 inventory + 4 armor + offhand; KitManager indexes armor at + // 36-39, so fewer slots would corrupt saves. + for (String id : List.of("player-kit-editor", "public-kit-editor", "inspect-kit", "public-kit-viewer")) { + assertEquals("kit-data", element(id, "data").getString("component"), id); + assertEquals(41, elementSlots(id, "data").size(), id); + } + } + + @Test + void enderchestEditorsExposeFullEnderchest() { + // Component names decide whose data renders: the stored enderchest + // kit vs the player's live enderchest (view-only). + assertEquals("enderchest-data", element("enderchest-editor", "data").getString("component")); + assertEquals("enderchest-data", element("inspect-enderchest", "data").getString("component")); + assertEquals("player-enderchest-data", element("view-only-enderchest", "data").getString("component")); + for (String id : List.of("enderchest-editor", "inspect-enderchest", "view-only-enderchest")) { + assertEquals(27, elementSlots(id, "data").size(), id); + } + } + + @Test + void adminUnassignedPublicKitsGiveFeedbackOnClick() { + // Left/right on an unassigned kit route through the load/viewer + // paths whose guards send the "how to assign" instructions; without + // these actions the admin workflow is a silent no-op. + ConfigurationSection variant = element("public-kit-menu", "kits") + .getConfigurationSection("variants.admin_unassigned"); + assertNotNull(variant); + assertEquals("load-public-kit", firstActionType(variant, "left")); + assertEquals("open-gui", firstActionType(variant, "right")); + } + + @Test + void kitRoomExposesFullPage() { + assertEquals("kit-room-data", element("kit-room", "data").getString("component")); + assertEquals(45, elementSlots("kit-room", "data").size()); + } + + @Test + void mainMenuSelectorsCoverNineColumns() { + // One column per kit slot of a page; SLOTS_PER_PAGE is 9. + for (String elementKey : List.of("kits", "enderchests", "kit-status")) { + assertEquals("kit-slot-selector", element("main-menu", elementKey).getString("component"), elementKey); + assertEquals(9, elementSlots("main-menu", elementKey).size(), elementKey); + } + } + + @Test + void templateAnchorsResolveThroughYamlConfiguration() { + // The armor indicators and editor tools are YAML merge keys; if + // SnakeYAML stopped flattening them, these elements would vanish. + for (String id : List.of("player-kit-editor", "public-kit-editor", "inspect-kit", "public-kit-viewer")) { + for (String indicator : List.of("boots", "leggings", "chestplate", "helmet", "offhand")) { + element(id, indicator); + } + } + for (String id : List.of("player-kit-editor", "public-kit-editor")) { + assertEquals("import-player-inventory", + firstActionType(element(id, "import"), "any"), id); + assertEquals("clear-editor", + firstActionType(element(id, "clear"), "shift"), id); + } + } + + @Test + void kitRoomControlIsPermissionGated() { + assertEquals("perplayerkit.editkitroom", element("kit-room", "back").getString("exclude-permission")); + assertEquals("perplayerkit.editkitroom", element("kit-room", "save").getString("permission")); + assertEquals(elementSlots("kit-room", "back"), elementSlots("kit-room", "save")); + } + + private static String firstActionType(ConfigurationSection element, String clickKey) { + List actions = element.getList("actions." + clickKey); + assertNotNull(actions, element.getCurrentPath() + " has no actions." + clickKey); + Object first = actions.get(0); + assertTrue(first instanceof java.util.Map, "action is not a map"); + return String.valueOf(((java.util.Map) first).get("type")); + } +} diff --git a/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java new file mode 100644 index 0000000..eb827b0 --- /dev/null +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java @@ -0,0 +1,73 @@ +package dev.noah.perplayerkit.gui.configurable; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuiSlotsTest { + + private final List warnings = new ArrayList<>(); + + private List parse(Object value) { + return GuiSlots.parse(value, 54, warnings::add); + } + + @Test + void parsesSingleNumber() { + assertEquals(List.of(37), parse(37)); + } + + @Test + void parsesRangeString() { + assertEquals(List.of(9, 10, 11, 12), parse("9-12")); + } + + @Test + void parsesMixedStringPreservingOrder() { + assertEquals(List.of(53, 0, 1, 2), parse("53, 0-2")); + } + + @Test + void parsesDescendingRange() { + assertEquals(List.of(12, 11, 10), parse("12-10")); + } + + @Test + void parsesListOfNumbersAndRanges() { + assertEquals(List.of(45, 47, 48, 49), parse(List.of(45, "47-49"))); + } + + @Test + void dropsDuplicates() { + assertEquals(List.of(1, 2, 3), parse("1-3,2")); + } + + @Test + void warnsAndSkipsSlotsOutsideMenu() { + assertEquals(List.of(53), parse("53-55")); + assertEquals(2, warnings.size()); + } + + @Test + void warnsOnGarbage() { + assertEquals(List.of(5), parse("5, oops")); + assertEquals(1, warnings.size()); + } + + @Test + void rejectsAbsurdRangesWithoutExpandingThem() { + assertTrue(parse("0-999999999").isEmpty()); + assertTrue(parse("0-" + Integer.MAX_VALUE).isEmpty()); + assertEquals(2, warnings.size()); + } + + @Test + void emptyWhenNull() { + assertTrue(parse(null).isEmpty()); + assertTrue(warnings.isEmpty()); + } +} diff --git a/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiTextTest.java b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiTextTest.java new file mode 100644 index 0000000..0c9af4c --- /dev/null +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiTextTest.java @@ -0,0 +1,90 @@ +package dev.noah.perplayerkit.gui.configurable; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class GuiTextTest { + + private final Map lang = Map.of( + "gui.kit-slot-name", "Kit {slot}", + "gui.main-menu-title-paged", "{player}'s Kits ({page}/{pages})"); + + private final Map context = Map.of( + "slot", "5", + "player", "Noah", + "page", "2", + "pages", "3", + "primary_color", ""); + + /** Mirrors the service wiring: the lang lookup applies context tokens itself, like Lang.raw(key, map). */ + private final UnaryOperator langLookup = key -> { + String value = lang.get(key); + if (value == null) { + return null; + } + for (Map.Entry entry : context.entrySet()) { + value = value.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return value; + }; + + private String resolve(String value) { + return GuiText.resolve(value, langLookup, context::get); + } + + @Test + void expandsLangKeyWithItsTokens() { + assertEquals("Kit 5", resolve("%lang:gui.kit-slot-name%")); + } + + @Test + void expandsPagedTitle() { + assertEquals("Noah's Kits (2/3)", resolve("%primary_color%%lang:gui.main-menu-title-paged%")); + } + + @Test + void expandsPercentTokens() { + assertEquals("Kit 5 for Noah", resolve("Kit %slot% for %player%")); + } + + @Test + void leavesUnknownPercentTokensForPlaceholderApi() { + assertEquals("hello %papi_placeholder%", resolve("hello %papi_placeholder%")); + } + + @Test + void leavesUnknownLangKeysIntact() { + assertEquals("%lang:gui.does-not-exist%", resolve("%lang:gui.does-not-exist%")); + } + + @Test + void nullPassesThrough() { + assertNull(resolve(null)); + } + + @Test + void resolveIntReadsNumbers() { + assertEquals(7, GuiText.resolveInt(7, context::get)); + } + + @Test + void resolveIntExpandsContextPlaceholders() { + // Regression: a "slot: %slot%" action argument must resolve to the + // context slot, not silently fall back to a default. + assertEquals(5, GuiText.resolveInt("%slot%", context::get)); + } + + @Test + void resolveIntNullWhenNotNumeric() { + Function empty = key -> null; + assertNull(GuiText.resolveInt("%slot%", empty)); + assertNull(GuiText.resolveInt("abc", empty)); + assertNull(GuiText.resolveInt(null, empty)); + } +}