From 8e6dcf3dc5fd1a14c4719b65a086359d9797704c Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:25:44 -0700 Subject: [PATCH 1/7] Widen GuiCompat visibility for use outside the gui package The configurable GUI engine (added next) lives in gui.configurable and needs the title-update capability check and in-place title updates. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; } From 868e07852dc5f43e231fab4576d5fd9fa9c18fea Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:26:04 -0700 Subject: [PATCH 2/7] Add GUI config primitives: slot parsing, placeholders, open context Bukkit-free building blocks for the configurable GUI engine: - GuiSlots parses slot declarations (numbers, ranges incl. descending, lists), order-preserving and bounds-checked against the menu size - GuiText expands %lang:% (lang lookup applies the message's own {token} placeholders) and %token% context values, leaving unknown tokens for PlaceholderAPI; resolveInt expands placeholders in numeric arguments so "slot: %slot%" means the context slot - GuiContext is the immutable value bag menus are opened with Co-Authored-By: Claude Fable 5 --- .../gui/configurable/GuiContext.java | 113 ++++++++++++++++++ .../gui/configurable/GuiSlots.java | 102 ++++++++++++++++ .../gui/configurable/GuiText.java | 93 ++++++++++++++ .../gui/configurable/GuiSlotsTest.java | 66 ++++++++++ .../gui/configurable/GuiTextTest.java | 90 ++++++++++++++ 5 files changed, 464 insertions(+) create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/GuiContext.java create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/GuiText.java create mode 100644 src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java create mode 100644 src/test/java/dev/noah/perplayerkit/gui/configurable/GuiTextTest.java 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..ba8e1e4 --- /dev/null +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java @@ -0,0 +1,102 @@ +/* + * 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 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; + } + 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/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..1195c8b --- /dev/null +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java @@ -0,0 +1,66 @@ +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 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)); + } +} From 84abd53916761a4ad6cb5dfd33cdbe639cf5b2a3 Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:26:20 -0700 Subject: [PATCH 3/7] Add guis.yml loading with bundled fallback and schema migration Menus missing from the user's guis.yml fall back to the copy bundled in the jar, per GUI (not per key), so shipping new menus never strands old installs. A file with an older config-version is archived to guis.yml.v.bak and regenerated with a warning: a schema bump can rename components or actions, and a stale file would otherwise shadow the bundled menus wholesale and half-work. Co-Authored-By: Claude Fable 5 --- .../gui/configurable/GuiConfigManager.java | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/GuiConfigManager.java 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); + } +} From d6261bfacb6a27f26eb22582f50813b7dcc8c3a2 Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:26:23 -0700 Subject: [PATCH 4/7] Expose KitRoomDataManager page count The kit room GUI is about to become config-driven; callers need the storage bound to clamp page indexes instead of running into the fixed five-page list's IndexOutOfBoundsException. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/noah/perplayerkit/KitRoomDataManager.java | 4 ++++ 1 file changed, 4 insertions(+) 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() { From 49916a25b6e26afb8a20b5133e347e18e495a888 Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:26:48 -0700 Subject: [PATCH 5/7] Add config-driven GUI engine and default guis.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigurableGuiService builds every menu from guis.yml: elements carry slots, items, permissions, and click actions; components (kit-slot selectors, pagination arrows, public-kit list, item-data regions, kit room categories) render plugin data into config-declared positions; variants give per-state items/actions with a named -> default -> element fallback per key. All default text routes through the lang system via %lang:key% placeholders, so the bundled translations apply and owners can restyle any menu; PlaceholderAPI works where installed. Repeated menu blocks (armor indicators, editor tools) are shared as YAML anchors, covered by a test proving YamlConfiguration flattens merge keys. Editor persistence keeps the session model the old GUI class proved out: an EditorSession registered on open records the data slots (from the menu's own config) and expected size; it is flushed by InventoryCloseEvent or before opening the next menu, since canvas redraw navigation reuses the open inventory and never fires a close event. Saves read the closing Bukkit inventory directly. Delete actions suppress the close-save even when the delete found nothing — the menu still shows stale contents and saving them would resurrect a kit another admin deleted. max-kits pagination is a main-menu component: kit slot numbers derive from the page in the open context, arrows appear only where valid, and back buttons return to the page a kit lives on (or the remembered page), while /kit always starts at page 1. Co-Authored-By: Claude Fable 5 --- .../configurable/ConfigurableGuiService.java | 1081 +++++++++++++++++ .../gui/configurable/EditorSession.java | 67 + src/main/resources/guis.yml | 766 ++++++++++++ .../configurable/BundledGuisConfigTest.java | 148 +++ 4 files changed, 2062 insertions(+) create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java create mode 100644 src/main/java/dev/noah/perplayerkit/gui/configurable/EditorSession.java create mode 100644 src/main/resources/guis.yml create mode 100644 src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java 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/resources/guis.yml b/src/main/resources/guis.yml new file mode 100644 index 0000000..e02a0c2 --- /dev/null +++ b/src/main/resources/guis.yml @@ -0,0 +1,766 @@ +# ----------------------------------------------------------------------------- +# 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-kit-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: "18-35" + item: + material: BOOK + name: "%lang:gui.more-kits-coming%" + kits: + order: 10 + type: component + component: public-kit-list + slots: "18-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..428ecba --- /dev/null +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java @@ -0,0 +1,148 @@ +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() { + 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")); + } +} From 850f224a9b5b81682d4a60bc4886b9562bf1c5de Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 21:27:00 -0700 Subject: [PATCH 6/7] Route all menus through the configurable GUI engine GUI becomes a thin facade over ConfigurableGuiService, keeping the entry points commands and listeners call; KitMenuCloseListener hands closing inventories to the service's editor sessions; the view-only enderchest and plugin startup wire in the service. EditorSaver, GuiMenuFactory, and GuiLayoutUtils are superseded by the engine and guis.yml. Co-Authored-By: Claude Fable 5 --- .../dev/noah/perplayerkit/PerPlayerKit.java | 2 + .../commands/kits/EnderchestCommand.java | 28 +- .../noah/perplayerkit/gui/EditorSaver.java | 113 --- .../java/dev/noah/perplayerkit/gui/GUI.java | 699 +----------------- .../noah/perplayerkit/gui/GuiLayoutUtils.java | 65 -- .../noah/perplayerkit/gui/GuiMenuFactory.java | 97 --- .../listeners/KitMenuCloseListener.java | 22 +- 7 files changed, 28 insertions(+), 998 deletions(-) delete mode 100644 src/main/java/dev/noah/perplayerkit/gui/EditorSaver.java delete mode 100644 src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java delete mode 100644 src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java 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/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/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()); } } From 14ac242ef15d6bd6fc4928e38e69040985b46b96 Mon Sep 17 00:00:00 2001 From: Noah Ross Date: Fri, 10 Jul 2026 23:19:12 -0700 Subject: [PATCH 7/7] Address CodeRabbit review: bound slot ranges, widen public kit list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GuiSlots rejects ranges wider than any menu before expanding them; a typo like "0-999999999" warned per-slot after ballooning the set (and could overflow at the int boundary) instead of being dropped outright - The public kit list gets 27 slots (the row above was filler glass); kits beyond the configured slots are still capped with a warning — the old code rendered them over the footer buttons instead - The enderchest editor's clear button says CLEAR ENDERCHEST, not CLEAR KIT (label carried over from the kit editor, also flagged on the original branch) - Close the guis.yml resource stream in the bundled-config test Co-Authored-By: Claude Fable 5 --- .../dev/noah/perplayerkit/gui/configurable/GuiSlots.java | 9 +++++++++ src/main/resources/guis.yml | 8 +++++--- .../gui/configurable/BundledGuisConfigTest.java | 9 +++++---- .../noah/perplayerkit/gui/configurable/GuiSlotsTest.java | 7 +++++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java index ba8e1e4..b42ccc1 100644 --- a/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java +++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/GuiSlots.java @@ -34,6 +34,8 @@ */ public final class GuiSlots { + private static final int MAX_RANGE_SIZE = 128; + private GuiSlots() { } @@ -85,6 +87,13 @@ private static void parseString(String value, LinkedHashSet slots, Cons 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); diff --git a/src/main/resources/guis.yml b/src/main/resources/guis.yml index e02a0c2..e3d0ec3 100644 --- a/src/main/resources/guis.yml +++ b/src/main/resources/guis.yml @@ -424,7 +424,7 @@ guis: slots: 52 item: material: BARRIER - name: "%lang:gui.clear-kit-button%" + name: "%lang:gui.clear-ec-button%" lore: - "%lang:gui.lore-shift-clear%" actions: @@ -627,7 +627,7 @@ guis: coming-soon: order: 5 type: static - slots: "18-35" + slots: "9-35" item: material: BOOK name: "%lang:gui.more-kits-coming%" @@ -635,7 +635,9 @@ guis: order: 10 type: component component: public-kit-list - slots: "18-35" + # 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: diff --git a/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java b/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java index 428ecba..b5cfe24 100644 --- a/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/BundledGuisConfigTest.java @@ -25,10 +25,11 @@ class BundledGuisConfigTest { private static YamlConfiguration config; @BeforeAll - static void load() { - var stream = BundledGuisConfigTest.class.getClassLoader().getResourceAsStream("guis.yml"); - assertNotNull(stream, "guis.yml missing from resources"); - config = YamlConfiguration.loadConfiguration(new InputStreamReader(stream, StandardCharsets.UTF_8)); + 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) { diff --git a/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java index 1195c8b..eb827b0 100644 --- a/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java +++ b/src/test/java/dev/noah/perplayerkit/gui/configurable/GuiSlotsTest.java @@ -58,6 +58,13 @@ void warnsOnGarbage() { 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());