From 82181c9d02621081558f9f9a0e4ba05a162992eb Mon Sep 17 00:00:00 2001 From: TheSilentPro <46107752+TheSilentPro@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:42:04 +0200 Subject: [PATCH 1/4] feat: rework menu navigation and custom content flows --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .mvn/wrapper/maven-wrapper.properties | 2 +- .../headdb/paper/HeadDBPlugin.java | 30 +- .../headdb/paper/command/Suggestions.java | 4 + .../subcommand/CollectionsCommand.java | 73 +- .../command/subcommand/CustomCommand.java | 2 +- .../paper/command/subcommand/TagsCommand.java | 73 +- .../headdb/paper/economy/EconomyService.java | 42 +- .../headdb/paper/gui/GuiService.java | 91 ++- .../headdb/paper/gui/category/BrowseMenu.java | 596 +++++++++++++++ .../gui/category/CategoryHeadPickerMenu.java | 20 +- .../gui/category/CategoryMembersMenu.java | 9 +- .../paper/gui/category/CollectionsMenu.java | 384 ++++++++++ .../gui/category/CreateCategoryMenu.java | 379 ++++++++++ .../paper/gui/category/CreateHeadMenu.java | 704 ++++++++++++++++++ .../gui/category/CreateTaxonomyMenu.java | 310 ++++++++ .../paper/gui/category/CustomCategory.java | 36 +- .../gui/category/CustomCategoryService.java | 52 +- .../gui/category/CustomCategoryViewMenu.java | 287 +++++++ .../category/DeleteCategoryConfirmMenu.java | 171 +++++ .../gui/category/MoreCategoriesMenu.java | 16 +- .../headdb/paper/gui/category/TagsMenu.java | 384 ++++++++++ .../headdb/paper/gui/common/GuiItems.java | 4 +- .../headdb/paper/gui/common/GuiLabels.java | 80 ++ .../headdb/paper/gui/common/GuiTitles.java | 31 +- .../headdb/paper/gui/config/GuiConfig.java | 32 + .../paper/gui/edit/DeleteHeadConfirmMenu.java | 198 +++++ .../paper/gui/edit/HeadEditListener.java | 167 ++++- .../headdb/paper/gui/edit/HeadEditMenu.java | 31 +- .../paper/gui/favorites/FavoritesMenu.java | 2 +- .../paper/gui/hidden/HiddenHeadsMenu.java | 11 +- .../paper/gui/local/LocalHeadListMenu.java | 18 +- .../paper/gui/main/MainPageFactory.java | 348 +-------- .../gui/material/MaterialSelectionMenu.java | 2 +- .../gui/search/SearchFilterPageFactory.java | 72 +- .../headdb/paper/gui/search/SearchMenu.java | 17 +- .../paper/gui/search/SearchMenuState.java | 22 +- .../gui/search/SearchOptionsPageFactory.java | 144 +++- .../paper/gui/search/SearchPageFactory.java | 91 ++- .../gui/settings/CommandConfirmMenu.java | 162 ++++ .../gui/settings/SettingsPageFactory.java | 72 +- .../paper/item/DefaultHeadItemFactory.java | 12 +- .../headdb/paper/local/HeadRegistry.java | 33 +- .../paper/local/custom/CustomHeadStore.java | 4 +- .../paper/local/custom/StoredCustomHead.java | 32 +- .../local/storage/StrataLocalStores.java | 9 +- .../local/taxonomy/CustomTaxonomyEntry.java | 67 ++ .../local/taxonomy/CustomTaxonomyService.java | 171 +++++ .../headdb/paper/message/MessageKey.java | 7 +- .../headdb/paper/message/Messages.java | 31 +- .../headdb/paper/permission/Permissions.java | 15 + .../headdb/paper/search/SearchRequest.java | 10 + .../headdb-paper/src/main/resources/gui.yml | 331 +++++++- .../src/main/resources/messages/en-US.yml | 12 + .../src/main/resources/paper-plugin.yml | 88 ++- 56 files changed, 5464 insertions(+), 531 deletions(-) create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyEntry.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce0bfd3..5e89025 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,7 +149,7 @@ jobs: fi grep -Fq "main: io.github.silentdevelopment.headdb.paper.HeadDBPlugin" /tmp/paper-plugin.yml - grep -Fq "api-version: '1.21'" /tmp/paper-plugin.yml + grep -Fq "api-version: '26.1.2'" /tmp/paper-plugin.yml grep -Fq 'Vault:' /tmp/paper-plugin.yml grep -Fq 'required: false' /tmp/paper-plugin.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52f5046..15f246d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,7 +153,7 @@ jobs: fi grep -Fq "main: io.github.silentdevelopment.headdb.paper.HeadDBPlugin" /tmp/paper-plugin.yml - grep -Fq "api-version: '1.21'" /tmp/paper-plugin.yml + grep -Fq "api-version: '26.1.2'" /tmp/paper-plugin.yml grep -Fq 'Vault:' /tmp/paper-plugin.yml grep -Fq 'required: false' /tmp/paper-plugin.yml diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 4eac81c..ffcab66 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip \ No newline at end of file +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java index 2dbd579..19befbd 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java @@ -26,6 +26,7 @@ import io.github.silentdevelopment.headdb.paper.local.player.PlayerHeadService; import io.github.silentdevelopment.headdb.paper.local.storage.SqliteStorageMigrator; import io.github.silentdevelopment.headdb.paper.local.storage.StrataLocalStores; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyService; import io.github.silentdevelopment.headdb.paper.local.storage.NoopLocalStores; import io.github.silentdevelopment.headdb.paper.message.Messages; import io.github.silentdevelopment.headdb.paper.metrics.HeadDBMetrics; @@ -60,6 +61,8 @@ public final class HeadDBPlugin extends JavaPlugin { private final AdminModeService adminModes = new AdminModeService(); private FavoriteHeadService favoriteHeadService; private CustomCategoryService customCategoryService; + private CustomTaxonomyService customTagService; + private CustomTaxonomyService customCollectionService; private EconomyService economyService; private UpdateService updateService; @@ -117,6 +120,8 @@ public void onDisable() { adminModes.clear(); favoriteHeadService = null; customCategoryService = null; + customTagService = null; + customCollectionService = null; economyService = null; updateService = null; runtime = null; @@ -146,6 +151,8 @@ public synchronized void reload() throws ConfigException { HeadItemFactory createdItemFactory = createItemFactory(loadedConfig); FavoriteHeadService createdFavoriteHeadService = new FavoriteHeadService(localStoreDatabase); CustomCategoryService createdCustomCategoryService = new CustomCategoryService(localStoreDatabase); + CustomTaxonomyService createdCustomTagService = new CustomTaxonomyService(localStoreDatabase, "tag", "Local custom tag."); + CustomTaxonomyService createdCustomCollectionService = new CustomTaxonomyService(localStoreDatabase, "collection", "Local custom collection."); EconomyService createdEconomyService = EconomyService.create(this, loadedEconomyConfig); GuiService createdGuiService = new GuiService(this, createdItemFactory); UpdateService createdUpdateService = new UpdateService(this, loadedConfig); @@ -170,6 +177,8 @@ public synchronized void reload() throws ConfigException { this.guiService = createdGuiService; this.favoriteHeadService = createdFavoriteHeadService; this.customCategoryService = createdCustomCategoryService; + this.customTagService = createdCustomTagService; + this.customCollectionService = createdCustomCollectionService; this.economyService = createdEconomyService; this.updateService = createdUpdateService; @@ -308,6 +317,23 @@ public synchronized void reloadGuiConfigOnly() throws ConfigException { } + + public @NotNull CustomTaxonomyService customTags() { + CustomTaxonomyService currentCustomTagService = customTagService; + if (currentCustomTagService == null) { + throw new IllegalStateException("HeadDB custom tag service is not initialized"); + } + return currentCustomTagService; + } + + public @NotNull CustomTaxonomyService customCollections() { + CustomTaxonomyService currentCustomCollectionService = customCollectionService; + if (currentCustomCollectionService == null) { + throw new IllegalStateException("HeadDB custom collection service is not initialized"); + } + return currentCustomCollectionService; + } + public @NotNull EconomyService economy() { EconomyService currentEconomyService = economyService; if (currentEconomyService == null) { @@ -388,7 +414,9 @@ private void unregisterServices() { ? new BukkitPlayerHeadService(this, playerHeadCache, config.playerHeadCacheTtl(), config.playerHeadFailedCacheTtl(), config.playerHeadsAllowExternalLookup()) : new DisabledPlayerHeadService(); - return new HeadRegistry(runtime.database(), overrideStore, customHeadStore, playerHeadService); + CustomTaxonomyService customTags = new CustomTaxonomyService(localStoreDatabase, "tag", "Local custom tag."); + CustomTaxonomyService customCollections = new CustomTaxonomyService(localStoreDatabase, "collection", "Local custom collection."); + return new HeadRegistry(runtime.database(), overrideStore, customHeadStore, playerHeadService, customTags, customCollections); } private @NotNull HeadItemFactory createItemFactory(@NotNull PluginConfig config) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java index edee1f3..f256a93 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java @@ -103,6 +103,10 @@ private Suggestions() { return context -> enumValues(context.currentToken(), SortDirection.values()); } + public static @NotNull SuggestionProvider taxonomyActions() { + return context -> matching(context.currentToken(), List.of("create", "delete")); + } + public static @NotNull SuggestionProvider pages() { return context -> matching(context.currentToken(), List.of("1", "2", "3", "4", "5", "10")); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java index a83be16..0bdb70c 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java @@ -6,12 +6,15 @@ import io.github.silentdevelopment.headdb.paper.command.Suggestions; import io.github.silentdevelopment.headdb.paper.command.format.ListFormatter; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; import io.github.silentdevelopment.relay.argument.Argument; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; import io.github.silentdevelopment.relay.paper.command.PaperCommands; import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.jetbrains.annotations.NotNull; import java.util.Comparator; @@ -23,6 +26,7 @@ public final class CollectionsCommand extends AbstractPaperCommand { private static final int PAGE_SIZE = 12; private static final Argument QUERY = Argument.optional("query", PaperArgumentTypes.STRING); private static final Argument PAGE = Argument.optional("page", PaperArgumentTypes.STRING); + private static final Argument VALUE = Argument.optional("value", PaperArgumentTypes.STRING); private final HeadDBPlugin plugin; @@ -32,6 +36,16 @@ public CollectionsCommand(@NotNull HeadDBPlugin plugin) { @Override protected void handle(@NotNull PaperCommandContext context) { + if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("create")) { + create(context); + return; + } + + if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("delete")) { + delete(context); + return; + } + ParsedListRequest request = request(context); var entries = plugin.headRegistry().collections().stream() @@ -51,13 +65,68 @@ protected void handle(@NotNull PaperCommandContext context) { .alias("col") .description("Lists head collections.") .requirement(CommandRequirements.permission(Permissions.SEARCH)) - .signature(QUERY, PAGE) - .suggest(QUERY, Suggestions.collections(plugin)) + .signature(QUERY, PAGE, VALUE) + .suggest(QUERY, Suggestions.taxonomyActions()) .suggest(PAGE, Suggestions.pages()) .noArgs() .build(); } + + private void create(@NotNull PaperCommandContext context) { + require(context, Permissions.COLLECTION_CREATE); + String id = required(context, PAGE, "Usage: /hdb collections create [name]"); + String name = context.has(VALUE) && !context.get(VALUE).trim().isBlank() ? context.get(VALUE).trim() : displayName(id); + java.util.UUID createdBy = context.isPlayer() ? context.player().getUniqueId() : null; + CustomTaxonomyEntry entry = plugin.customCollections().create(id, name, createdBy); + plugin.headRegistry().onLocalMutation(); + plugin.clearSearchCache(); + context.reply(plugin.messages().taxonomyCreated(context.sender(), "collection", entry.name(), entry.id())); + } + + + private void delete(@NotNull PaperCommandContext context) { + require(context, Permissions.COLLECTION_DELETE); + String id = required(context, PAGE, "Usage: /hdb collections delete "); + boolean deleted = plugin.customCollections().delete(id); + if (!deleted) { + context.reply(plugin.messages().taxonomyUnknown(context.sender(), "collection", id)); + return; + } + + plugin.headRegistry().onLocalMutation(); + plugin.clearSearchCache(); + context.reply(plugin.messages().taxonomyDeleted(context.sender(), "collection", id)); + } + + private static void require(@NotNull PaperCommandContext context, @NotNull String permission) { + if (!Permissions.has(context.sender(), permission)) { + throw new IllegalArgumentException("You do not have permission to do that."); + } + } + + private static @NotNull String required(@NotNull PaperCommandContext context, @NotNull Argument argument, @NotNull String message) { + if (!context.has(argument) || context.get(argument).trim().isBlank()) { + throw new IllegalArgumentException(message); + } + return context.get(argument).trim(); + } + + private static @NotNull String displayName(@NotNull String id) { + String normalized = id.trim().replace('_', '-'); + if (normalized.isBlank()) { + return "Custom Collection"; + } + java.util.List words = new java.util.ArrayList<>(); + for (String part : normalized.split("-")) { + if (part.isBlank()) { + continue; + } + words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1)); + } + return words.isEmpty() ? normalized : String.join(" ", words); + } + private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) { if (query.isBlank()) { return true; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java index 9f32bc6..f72dd6a 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java @@ -88,7 +88,7 @@ protected void handle(@NotNull PaperCommandContext context) { private void list(@NotNull PaperCommandContext context) { require(context, Permissions.CUSTOM_LIST); - List heads = plugin.headRegistry().customHeads().listStored().stream().sorted(Comparator.comparing(StoredCustomHead::id)).toList(); + List heads = plugin.headRegistry().customHeads().listStored().stream().filter(head -> !head.draft()).sorted(Comparator.comparing(StoredCustomHead::id)).toList(); int page = context.has(FIRST) ? page(context.get(FIRST)) : 1; int from = Math.min((page - 1) * 10, heads.size()); int to = Math.min(from + 10, heads.size()); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java index 7f15a65..e694926 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java @@ -6,12 +6,15 @@ import io.github.silentdevelopment.headdb.paper.command.Suggestions; import io.github.silentdevelopment.headdb.paper.command.format.ListFormatter; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; import io.github.silentdevelopment.relay.argument.Argument; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; import io.github.silentdevelopment.relay.paper.command.PaperCommands; import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.jetbrains.annotations.NotNull; import java.util.Comparator; @@ -23,6 +26,7 @@ public final class TagsCommand extends AbstractPaperCommand { private static final int PAGE_SIZE = 12; private static final Argument QUERY = Argument.optional("query", PaperArgumentTypes.STRING); private static final Argument PAGE = Argument.optional("page", PaperArgumentTypes.STRING); + private static final Argument VALUE = Argument.optional("value", PaperArgumentTypes.STRING); private final HeadDBPlugin plugin; @@ -32,6 +36,16 @@ public TagsCommand(@NotNull HeadDBPlugin plugin) { @Override protected void handle(@NotNull PaperCommandContext context) { + if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("create")) { + create(context); + return; + } + + if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("delete")) { + delete(context); + return; + } + ParsedListRequest request = request(context); var entries = plugin.headRegistry().tags().stream() @@ -51,13 +65,68 @@ protected void handle(@NotNull PaperCommandContext context) { .alias("t") .description("Lists tags.") .requirement(CommandRequirements.permission(Permissions.SEARCH)) - .signature(QUERY, PAGE) - .suggest(QUERY, Suggestions.tags(plugin)) + .signature(QUERY, PAGE, VALUE) + .suggest(QUERY, Suggestions.taxonomyActions()) .suggest(PAGE, Suggestions.pages()) .noArgs() .build(); } + + private void create(@NotNull PaperCommandContext context) { + require(context, Permissions.TAG_CREATE); + String id = required(context, PAGE, "Usage: /hdb tags create [name]"); + String name = context.has(VALUE) && !context.get(VALUE).trim().isBlank() ? context.get(VALUE).trim() : displayName(id); + java.util.UUID createdBy = context.isPlayer() ? context.player().getUniqueId() : null; + CustomTaxonomyEntry entry = plugin.customTags().create(id, name, createdBy); + plugin.headRegistry().onLocalMutation(); + plugin.clearSearchCache(); + context.reply(plugin.messages().taxonomyCreated(context.sender(), "tag", entry.name(), entry.id())); + } + + + private void delete(@NotNull PaperCommandContext context) { + require(context, Permissions.TAG_DELETE); + String id = required(context, PAGE, "Usage: /hdb tags delete "); + boolean deleted = plugin.customTags().delete(id); + if (!deleted) { + context.reply(plugin.messages().taxonomyUnknown(context.sender(), "tag", id)); + return; + } + + plugin.headRegistry().onLocalMutation(); + plugin.clearSearchCache(); + context.reply(plugin.messages().taxonomyDeleted(context.sender(), "tag", id)); + } + + private static void require(@NotNull PaperCommandContext context, @NotNull String permission) { + if (!Permissions.has(context.sender(), permission)) { + throw new IllegalArgumentException("You do not have permission to do that."); + } + } + + private static @NotNull String required(@NotNull PaperCommandContext context, @NotNull Argument argument, @NotNull String message) { + if (!context.has(argument) || context.get(argument).trim().isBlank()) { + throw new IllegalArgumentException(message); + } + return context.get(argument).trim(); + } + + private static @NotNull String displayName(@NotNull String id) { + String normalized = id.trim().replace('_', '-'); + if (normalized.isBlank()) { + return "Custom Tag"; + } + java.util.List words = new java.util.ArrayList<>(); + for (String part : normalized.split("-")) { + if (part.isBlank()) { + continue; + } + words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1)); + } + return words.isEmpty() ? normalized : String.join(" ", words); + } + private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) { if (query.isBlank()) { return true; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java index 6ad7303..6d268ca 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java @@ -3,17 +3,22 @@ import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import net.milkbowl.vault.economy.Economy; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.RegisteredServiceProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; import java.util.Objects; public final class EconomyService { private final HeadDBPlugin plugin; - private final EconomyConfig config; + private EconomyConfig config; private final EconomyProvider provider; private EconomyService(@NotNull HeadDBPlugin plugin, @NotNull EconomyConfig config, @NotNull EconomyProvider provider) { @@ -114,7 +119,42 @@ public boolean charge(@NotNull Player player, @NotNull Head head, int amount, @N return false; } + public void setHeadPrice(@NotNull io.github.silentdevelopment.headdb.model.HeadId id, double price) { + Objects.requireNonNull(id, "id"); + setPrice("prices.heads." + id.display().trim().toLowerCase(Locale.ROOT), price); + } + + public void setCustomCategoryPrice(@NotNull String categoryId, double price) { + Objects.requireNonNull(categoryId, "categoryId"); + setPrice("prices.custom-categories." + categoryId.trim().toLowerCase(Locale.ROOT), price); + } + public @NotNull String format(double amount) { return provider.format(amount); } + + private void setPrice(@NotNull String path, double price) { + if (price < 0.0D) { + throw new IllegalArgumentException("Price cannot be negative."); + } + + Path file = plugin.getDataFolder().toPath().resolve("economy.yml"); + try { + Path parent = file.toAbsolutePath().normalize().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file.toFile()); + if (price <= 0.0D) { + yaml.set(path, null); + } else { + yaml.set(path, price); + } + yaml.save(file.toFile()); + config = EconomyConfig.load(plugin); + } catch (IOException exception) { + throw new IllegalStateException("Failed to update economy.yml.", exception); + } + } } \ No newline at end of file diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java index 23eebc9..ce65602 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java @@ -5,6 +5,9 @@ import io.github.silentdevelopment.grafik.paper.core.PaperGrafiks; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.main.MainMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.BrowseMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CollectionsMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.TagsMenu; import io.github.silentdevelopment.headdb.paper.gui.category.MoreCategoriesMenu; import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu; import io.github.silentdevelopment.headdb.paper.gui.local.LocalHeadListMenu; @@ -26,6 +29,7 @@ public final class GuiService { private final PaperGrafik grafik; private final DynamicGui mainGui; private final DynamicGui searchGui; + private final DynamicGui advancedSearchGui; private final SearchResultCache searchResultCache; public GuiService(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFactory) { @@ -36,6 +40,7 @@ public GuiService(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFac this.grafik = PaperGrafiks.create(plugin); this.mainGui = grafik.prepare(new MainMenu(plugin)); this.searchGui = grafik.prepare(new SearchMenu(plugin, itemFactory, searchResultCache)); + this.advancedSearchGui = grafik.prepare(SearchMenu.advanced(plugin, itemFactory, searchResultCache)); } public void openMain(@NotNull Player player) { @@ -49,26 +54,94 @@ public void openMain(@NotNull Player player) { grafik.open(mainGui, new MenuState(player.getUniqueId()), player); } - public void openSearch(@NotNull Player player, @NotNull SearchRequest request) { + public void openBrowse(@NotNull Player player) { + Objects.requireNonNull(player, "player"); + + if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return; + } + + BrowseMenu.open(plugin, player); + } + + public void openAdvancedSearch(@NotNull Player player) { + openAdvancedSearch(player, new SearchRequest("", java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), io.github.silentdevelopment.headdb.query.HeadSort.RELEVANCE, io.github.silentdevelopment.headdb.query.SortDirection.DESCENDING, 1, 28, false), SearchMenuState.BackTarget.MAIN); + } + + public void openAdvancedSearch(@NotNull Player player, @NotNull SearchRequest request, @NotNull SearchMenuState.BackTarget backTarget) { Objects.requireNonNull(player, "player"); Objects.requireNonNull(request, "request"); + Objects.requireNonNull(backTarget, "backTarget"); - if (request.category() != null) { - if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, request.category())) { - player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); - return; - } - } else if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) { + if (!Permissions.has(player, Permissions.GUI_SEARCH_ADVANCED) || !Permissions.has(player, Permissions.GUI_FILTER)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); return; } - if (!request.query().isBlank() && !Permissions.has(player, Permissions.SEARCH)) { + grafik.open(advancedSearchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, backTarget), player); + } + + public void openCollections(@NotNull Player player) { + Objects.requireNonNull(player, "player"); + + if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return; + } + + CollectionsMenu.open(plugin, player); + } + + public void openTags(@NotNull Player player) { + Objects.requireNonNull(player, "player"); + + if (!Permissions.has(player, Permissions.GUI_TAGS)) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return; + } + + TagsMenu.open(plugin, player); + } + + public void openSearch(@NotNull Player player, @NotNull SearchRequest request) { + openSearch(player, request, SearchMenuState.BackTarget.MAIN); + } + + public void openSearch(@NotNull Player player, @NotNull SearchRequest request, @NotNull SearchMenuState.BackTarget backTarget) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(backTarget, "backTarget"); + + if (!canOpenSearch(player, request)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); return; } - grafik.open(searchGui, new SearchMenuState(player.getUniqueId(), request), player); + grafik.open(searchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, SearchMenuState.BackTarget.RESULTS), player); + } + + private boolean canOpenSearch(@NotNull Player player, @NotNull SearchRequest request) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(request, "request"); + + if (!request.query().isBlank() && !Permissions.has(player, Permissions.SEARCH)) { + return false; + } + + if (request.categoryLocked()) { + return request.category() != null && Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) && Permissions.canViewCategory(player, request.category()); + } + + if (!request.categories().isEmpty()) { + return Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) && request.categories().stream().allMatch(category -> Permissions.canViewCategory(player, category)); + } + + if (request.isEmpty()) { + return Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player); + } + + return Permissions.has(player, Permissions.GUI_BROWSE) || Permissions.has(player, Permissions.GUI_SEARCH); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java new file mode 100644 index 0000000..a823e95 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java @@ -0,0 +1,596 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadCategory; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu; +import io.github.silentdevelopment.headdb.paper.gui.config.GuiIconConfig; +import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu; +import io.github.silentdevelopment.headdb.paper.gui.search.SearchMenuState; +import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.search.SearchRequest; +import io.github.silentdevelopment.headdb.query.HeadSort; +import io.github.silentdevelopment.headdb.query.SortDirection; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class BrowseMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_BACK = 45; + private static final int SLOT_PREVIOUS = 48; + private static final int SLOT_INFO = 49; + private static final int SLOT_NEXT = 50; + private static final String ACTION_BACK = "back"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_NEXT = "next"; + private static final String ACTION_BROWSE_ALL = "browse-all"; + private static final String ACTION_HIDDEN = "hidden"; + private static final String ACTION_TAGS = "tags"; + private static final String ACTION_COLLECTIONS = "collections"; + private static final String ACTION_SEARCH = "search"; + private static final String ACTION_CREATE_HEAD = "create-head"; + private static final String ACTION_CREATE_CATEGORY = "create-category"; + private static final String ACTION_CUSTOM_HEADS = "custom-heads"; + private static final String ACTION_CATEGORY = "category:"; + private static final int[] CATEGORY_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private BrowseMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + open(plugin, player, 0); + } + + private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + + if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) { + noPermission(plugin, player); + return; + } + + List categories = categories(plugin, player); + int pages = pageCount(categories.size()); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + BrowseHolder holder = new BrowseHolder(page); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text("title.browse", "Browse"), plugin.adminModes().enabled(player))); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderTopActions(plugin, player, inventory); + renderCategories(plugin, player, inventory, categories, page); + renderControls(plugin, player, inventory, categories.size(), page, pages); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof BrowseHolder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + handleAction(plugin, player, holder, action.get(), event.getClick()); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull BrowseHolder holder, @NotNull String action, @NotNull ClickType click) { + if (action.equals(ACTION_BACK)) { + plugin.guis().openMain(player); + return; + } + + if (action.equals(ACTION_PREVIOUS)) { + open(plugin, player, holder.page() - 1); + return; + } + + if (action.equals(ACTION_NEXT)) { + open(plugin, player, holder.page() + 1); + return; + } + + if (action.equals(ACTION_BROWSE_ALL)) { + if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) { + noPermission(plugin, player); + return; + } + + plugin.guis().openSearch(player, browseRequest(), SearchMenuState.BackTarget.BROWSE); + return; + } + + if (action.equals(ACTION_HIDDEN)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) { + noPermission(plugin, player); + return; + } + + HiddenHeadsMenu.open(plugin, player); + return; + } + + if (action.equals(ACTION_TAGS)) { + if (!Permissions.has(player, Permissions.GUI_TAGS)) { + noPermission(plugin, player); + return; + } + + TagsMenu.open(plugin, player); + return; + } + + if (action.equals(ACTION_COLLECTIONS)) { + if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) { + noPermission(plugin, player); + return; + } + + CollectionsMenu.open(plugin, player); + return; + } + + if (action.equals(ACTION_SEARCH)) { + if (!Permissions.has(player, Permissions.GUI_SEARCH) || !Permissions.has(player, Permissions.SEARCH)) { + noPermission(plugin, player); + return; + } + + if (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) { + plugin.guis().openAdvancedSearch(player, browseRequest(), SearchMenuState.BackTarget.BROWSE); + return; + } + + player.closeInventory(); + player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter a search query.", NamedTextColor.GOLD), query -> { + plugin.guis().openSearch(player, new SearchRequest(query, Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.RELEVANCE, SortDirection.DESCENDING, 1, 28, false), SearchMenuState.BackTarget.BROWSE); + }, () -> { + player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY)); + open(plugin, player, holder.page()); + }), () -> {}); + return; + } + + if (action.equals(ACTION_CREATE_HEAD)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) { + noPermission(plugin, player); + return; + } + + CreateHeadMenu.openNew(plugin, player); + return; + } + + if (action.equals(ACTION_CUSTOM_HEADS)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) { + noPermission(plugin, player); + return; + } + + plugin.guis().openCustomHeads(player); + return; + } + + if (action.equals(ACTION_CREATE_CATEGORY)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) { + noPermission(plugin, player); + return; + } + + CreateCategoryMenu.open(plugin, player); + return; + } + + if (!action.startsWith(ACTION_CATEGORY)) { + return; + } + + String category = action.substring(ACTION_CATEGORY.length()); + Optional customCategory = plugin.customCategories().find(category); + if (customCategory.isPresent() && (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) && plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) { + CreateCategoryMenu.openExisting(plugin, player, customCategory.get().id()); + return; + } + + if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, category)) { + noPermission(plugin, player); + return; + } + + if (customCategory.isPresent()) { + CustomCategoryViewMenu.open(plugin, player, customCategory.get().id(), 0); + return; + } + + plugin.guis().openSearch(player, categoryRequest(category), SearchMenuState.BackTarget.BROWSE); + } + + private static void renderTopActions(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory) { + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.hidden-heads", 35), "hidden-heads", ACTION_HIDDEN); + } + + if (Permissions.has(player, Permissions.GUI_TAGS)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.tags", 2), "tags", ACTION_TAGS); + } + + if (Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.all", 4), "browse-all", ACTION_BROWSE_ALL); + } + + if (Permissions.has(player, Permissions.GUI_COLLECTIONS)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.collections", 6), "collections", ACTION_COLLECTIONS); + } + + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.create-category", 18), "create-category", ACTION_CREATE_CATEGORY); + } + + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.custom-heads", 26), "custom-heads", ACTION_CUSTOM_HEADS); + } + + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_HEAD)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.create-head", 27), "create-head", ACTION_CREATE_HEAD); + } + + if (Permissions.has(player, Permissions.GUI_SEARCH)) { + setAction(plugin, inventory, plugin.guiConfig().slot("browse.search", 53), "search", ACTION_SEARCH); + } + } + + private static void setAction(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, int slot, @NotNull String iconKey, @NotNull String action) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey)); + stamp(plugin, item, action); + inventory.setItem(slot, item); + } + + private static void renderCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List categories, int page) { + boolean adminMode = plugin.adminModes().enabled(player); + Map counts = plugin.headRegistry().categoryCounts(adminMode); + Map iconHeads = categoryIconHeads(plugin, categories, adminMode); + Map customCategories = customCategories(plugin); + int fromIndex = page * CATEGORY_SLOTS.length; + int toIndex = Math.min(categories.size(), fromIndex + CATEGORY_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + HeadCategory category = categories.get(index); + CustomCategory customCategory = customCategories.get(category.id()); + int amount = customCategory == null ? counts.getOrDefault(category.id(), 0) : customCategoryCount(plugin, customCategory, adminMode); + ItemStack item = customCategory == null ? categoryItem(plugin, category, iconHeads.get(category.id()), amount, adminMode) : customCategoryItem(plugin, category, customCategory, amount, adminMode); + stamp(plugin, item, ACTION_CATEGORY + category.id()); + inventory.setItem(CATEGORY_SLOTS[slotIndex], item); + slotIndex++; + } + } + + + private static int customCategoryCount(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category, boolean adminMode) { + int count = 0; + for (HeadId id : category.headIds()) { + boolean visible = id.isCustom() && adminMode ? plugin.headRegistry().customHeads().findStored(id).isPresent() : plugin.headRegistry().find(id).isPresent(); + if (visible) { + count++; + } + } + return count; + } + + private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int categoryCount, int page, int pages) { + ItemStack back = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("back")); + stamp(plugin, back, ACTION_BACK); + inventory.setItem(SLOT_BACK, back); + + if (page > 0) { + ItemStack previous = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("previous")); + stamp(plugin, previous, ACTION_PREVIOUS); + inventory.setItem(SLOT_PREVIOUS, previous); + } + + ItemStack info = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of( + GuiItems.idDetail("Categories", categoryCount), + GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)) + )); + inventory.setItem(SLOT_INFO, info); + + if (page + 1 < pages) { + ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next")); + stamp(plugin, next, ACTION_NEXT); + inventory.setItem(SLOT_NEXT, next); + } + } + + + private static @NotNull ItemStack customCategoryItem(@NotNull HeadDBPlugin plugin, @NotNull HeadCategory category, @NotNull CustomCategory customCategory, int amount, boolean adminMode) { + ItemStack item; + if (customCategory.headIcon()) { + item = plugin.headRegistry().find(new HeadId(customCategory.headIconId())).map(plugin.itemFactory()::create).orElseGet(() -> GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category"))); + } else { + item = GuiItems.item(GuiMaterials.itemOr(customCategory.material(), Material.CHEST), Component.empty(), List.of()); + } + + item.editMeta(meta -> { + List lore = new ArrayList<>(); + if (!customCategory.description().isBlank()) { + lore.add(GuiItems.lore(customCategory.description(), NamedTextColor.GRAY)); + lore.add(Component.empty()); + } + if (adminMode) { + lore.add(GuiItems.idDetail("Heads", amount)); + lore.add(GuiItems.idDetail("ID", customCategory.id())); + lore.add(GuiItems.idDetail("Permission", Permissions.category(customCategory.id()))); + if (customCategory.draft()) { + lore.add(GuiItems.idDetail("State", "DRAFT")); + } + lore.add(Component.empty()); + } + lore.add(GuiItems.lore("Click to browse this category.", NamedTextColor.GREEN)); + if (adminMode) { + lore.add(GuiItems.lore("Right-click to edit this custom category.", NamedTextColor.YELLOW)); + } + + meta.displayName(GuiItems.name(customCategory.draft() ? "DRAFT - " + customCategory.name() : customCategory.name(), customCategory.draft() ? NamedTextColor.YELLOW : NamedTextColor.GOLD)); + meta.lore(lore); + meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin)); + }); + return item; + } + + private static @NotNull ItemStack categoryItem(@NotNull HeadDBPlugin plugin, @NotNull HeadCategory category, Head iconHead, int amount, boolean adminMode) { + String iconKey = categoryIconKey(category.id()); + boolean configured = plugin.guiConfig().hasIcon(iconKey); + GuiIconConfig icon = plugin.guiConfig().iconOrDefault(iconKey, "category"); + ItemStack item = categoryIcon(plugin, iconHead, configured, icon); + item.editMeta(meta -> { + List lore = new ArrayList<>(); + + if (configured) { + lore.addAll(GuiItems.miniLore(replaceAll(icon.lore(), category, amount))); + } else if (category.description() != null && !category.description().isBlank()) { + lore.add(Component.text(category.description(), NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + } + + if (!lore.isEmpty()) { + lore.add(Component.empty()); + } + + if (adminMode) { + lore.add(GuiItems.idDetail("Heads", amount)); + lore.add(GuiItems.idDetail("ID", category.id())); + lore.add(Component.empty()); + } + + lore.add(GuiItems.lore("Click to browse this category.", NamedTextColor.GREEN)); + Component name = configured ? GuiItems.mini(replace(icon.name(), category, amount)) : GuiItems.name(category.name(), NamedTextColor.GOLD); + meta.displayName(name); + meta.lore(lore); + meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin)); + meta.getPersistentDataContainer().set(GuiButtonEditorMenu.iconKey(plugin), PersistentDataType.STRING, iconKey); + }); + return item; + } + + private static @NotNull ItemStack categoryIcon(@NotNull HeadDBPlugin plugin, Head iconHead, boolean configured, @NotNull GuiIconConfig icon) { + if (configured) { + return GuiHeadIcons.icon(plugin, icon); + } + + if (iconHead == null) { + return GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category")); + } + + return plugin.itemFactory().create(iconHead); + } + + private static @NotNull String categoryIconKey(@NotNull String categoryId) { + return "category." + categoryId.trim().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_"); + } + + private static @NotNull String replace(@NotNull String value, @NotNull HeadCategory category, int amount) { + return value.replace("%name%", category.name()).replace("%id%", category.id()).replace("%count%", String.valueOf(amount)); + } + + private static @NotNull List replaceAll(@NotNull List values, @NotNull HeadCategory category, int amount) { + List result = new ArrayList<>(); + for (String value : values) { + result.add(replace(value, category, amount)); + } + return List.copyOf(result); + } + + private static @NotNull Map categoryIconHeads(@NotNull HeadDBPlugin plugin, @NotNull List categories, boolean includeHidden) { + Map icons = new LinkedHashMap<>(); + Set missing = new HashSet<>(); + for (HeadCategory category : categories) { + missing.add(category.id()); + } + + if (missing.isEmpty()) { + return icons; + } + + for (Head head : plugin.headRegistry().heads(includeHidden)) { + String category = head.category(); + if (!missing.contains(category)) { + continue; + } + + icons.put(category, head); + missing.remove(category); + if (missing.isEmpty()) { + return icons; + } + } + + return icons; + } + + private static @NotNull List categories(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + boolean adminMode = plugin.adminModes().enabled(player); + Map customById = customCategories(plugin); + List remoteCategories = plugin.headRegistry().categories().stream() + .filter(category -> !customById.containsKey(category.id())) + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + List customCategories = plugin.customCategories().listVisible(adminMode).stream() + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER)) + .map(CustomCategory::toHeadCategory) + .toList(); + + List result = new ArrayList<>(); + result.addAll(remoteCategories); + result.addAll(customCategories); + return List.copyOf(result); + } + + private static @NotNull Map customCategories(@NotNull HeadDBPlugin plugin) { + Map categories = new LinkedHashMap<>(); + for (CustomCategory category : plugin.customCategories().list()) { + categories.put(category.id(), category); + } + return Map.copyOf(categories); + } + + private static int pageCount(int entries) { + if (entries <= 0) { + return 1; + } + return (int) Math.ceil((double) entries / (double) CATEGORY_SLOTS.length); + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (GuiMaterials.isAir(item.getType())) { + return Optional.empty(); + } + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "browse_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static @NotNull SearchRequest browseRequest() { + return new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false); + } + + private static @NotNull SearchRequest categoryRequest(@NotNull String category) { + return new SearchRequest("", Set.of(), Set.of(category), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, true); + } + + private static final class BrowseHolder implements InventoryHolder { + + private final int page; + private Inventory inventory; + + private BrowseHolder(int page) { + this.page = page; + } + + private int page() { + return page; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java index 83bdc54..39f064d 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java @@ -7,6 +7,7 @@ import io.github.silentdevelopment.headdb.paper.command.search.SearchParser; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels; import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; @@ -38,7 +39,7 @@ public final class CategoryHeadPickerMenu { private static final int SLOT_BACK = 45; private static final int SLOT_PREVIOUS = 48; private static final int SLOT_TYPE = 49; - private static final int SLOT_NEXT = 53; + private static final int SLOT_NEXT = 50; private static final String ACTION_BACK = "back"; private static final String ACTION_PREVIOUS = "previous"; private static final String ACTION_NEXT = "next"; @@ -143,7 +144,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action, @NotNull ItemStack item) { if (action.equals(ACTION_BACK)) { plugin.customCategories().find(holder.categoryId()).ifPresentOrElse( - category -> MoreCategoriesMenu.openEdit(plugin, player, category.id(), category.name(), category.material()), + category -> CreateCategoryMenu.openExisting(plugin, player, category.id()), () -> MoreCategoriesMenu.open(plugin, player) ); return; @@ -188,7 +189,8 @@ private static void promptId(@NotNull HeadDBPlugin plugin, @NotNull Player playe return; } - if (plugin.headRegistry().find(id).isEmpty()) { + boolean exists = id.isCustom() ? plugin.headRegistry().customHeads().findStored(id).isPresent() : plugin.headRegistry().find(id).isPresent(); + if (!exists) { player.sendMessage(Component.text("Unknown head: ", NamedTextColor.RED).append(Component.text(id.display(), NamedTextColor.GOLD))); open(plugin, player, categoryId, 0); return; @@ -200,12 +202,20 @@ private static void promptId(@NotNull HeadDBPlugin plugin, @NotNull Player playe private static void add(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId, @NotNull HeadId id) { plugin.customCategories().addHead(categoryId, id); - player.sendMessage(Component.text("Head added: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD))); + player.sendMessage(Component.text("Head added: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); CategoryMembersMenu.open(plugin, player, categoryId, 0); } private static @NotNull List heads(@NotNull HeadDBPlugin plugin, @NotNull Player player) { - List heads = new ArrayList<>(plugin.headRegistry().heads(plugin.adminModes().enabled(player))); + boolean adminMode = plugin.adminModes().enabled(player); + List heads = new ArrayList<>(plugin.headRegistry().heads(adminMode)); + if (adminMode) { + for (io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead head : plugin.headRegistry().customHeads().listStored()) { + if (head.draft()) { + heads.add(head.toHead()); + } + } + } for (PlayerHeadEntry entry : plugin.headRegistry().playerHeads().knownPlayers()) { heads.add(playerHead(entry)); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java index 8dfc431..d325017 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java @@ -5,6 +5,7 @@ import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels; import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import net.kyori.adventure.text.Component; @@ -31,7 +32,7 @@ public final class CategoryMembersMenu { private static final int SLOT_BACK = 45; private static final int SLOT_PREVIOUS = 48; private static final int SLOT_ADD = 49; - private static final int SLOT_NEXT = 53; + private static final int SLOT_NEXT = 50; private static final String ACTION_BACK = "back"; private static final String ACTION_PREVIOUS = "previous"; private static final String ACTION_NEXT = "next"; @@ -116,7 +117,7 @@ private static void renderMembers(@NotNull HeadDBPlugin plugin, @NotNull Invento } private static @NotNull ItemStack memberItem(@NotNull HeadDBPlugin plugin, @NotNull HeadId id) { - Optional head = plugin.headRegistry().find(id); + Optional head = id.isCustom() ? plugin.headRegistry().customHeads().findStored(id).map(stored -> stored.toHead()) : plugin.headRegistry().find(id); if (head.isPresent()) { ItemStack item = plugin.itemFactory().create(head.get()); item.editMeta(meta -> { @@ -146,7 +147,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) { if (action.equals(ACTION_BACK)) { plugin.customCategories().find(holder.categoryId()).ifPresentOrElse( - category -> MoreCategoriesMenu.openEdit(plugin, player, category.id(), category.name(), category.material()), + category -> CreateCategoryMenu.openExisting(plugin, player, category.id()), () -> MoreCategoriesMenu.open(plugin, player) ); return; @@ -180,7 +181,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p } plugin.customCategories().removeHead(holder.categoryId(), id); - player.sendMessage(Component.text("Head removed: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD))); + player.sendMessage(Component.text("Head removed: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); open(plugin, player, holder.categoryId(), holder.page()); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java new file mode 100644 index 0000000..ef04ea5 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java @@ -0,0 +1,384 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadCollection; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.search.SearchRequest; +import io.github.silentdevelopment.headdb.paper.gui.search.SearchMenuState; +import io.github.silentdevelopment.headdb.query.HeadSort; +import io.github.silentdevelopment.headdb.query.SortDirection; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class CollectionsMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_BACK = 45; + private static final int SLOT_PREVIOUS = 48; + private static final int SLOT_INFO = 49; + private static final int SLOT_NEXT = 50; + private static final int SLOT_CREATE = 27; + private static final int SLOT_FILTER = 52; + private static final int SLOT_SEARCH = 53; + private static final String ACTION_BACK = "back"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_NEXT = "next"; + private static final String ACTION_CREATE = "create"; + private static final String ACTION_FILTER = "filter"; + private static final String ACTION_SEARCH = "search"; + private static final String ACTION_ENTRY = "collection:"; + private static final int[] ENTRY_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private CollectionsMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + open(plugin, player, 0, false, ""); + } + + private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage, boolean customOnly, @NotNull String query) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(query, "query"); + + if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) { + noPermission(plugin, player); + return; + } + + boolean adminMode = plugin.adminModes().enabled(player); + Set customIds = customIds(plugin); + Map counts = counts(visibleHeads(plugin, player)); + List entries = plugin.headRegistry().collections().stream() + .filter(entry -> adminMode || counts.containsKey(entry.id())) + .filter(entry -> !customOnly || customIds.contains(entry.id())) + .filter(entry -> matches(entry.id(), entry.name(), query)) + .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + int pages = pageCount(entries.size()); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + Holder holder = new Holder(page, customOnly, query.trim()); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages, customOnly, query), adminMode)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderEntries(plugin, player, inventory, entries, counts, customIds, page); + renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + handleAction(plugin, player, holder, action.get()); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) { + if (action.equals(ACTION_BACK)) { + plugin.guis().openBrowse(player); + return; + } + + if (action.equals(ACTION_PREVIOUS)) { + open(plugin, player, holder.page() - 1, holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_NEXT)) { + open(plugin, player, holder.page() + 1, holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_CREATE)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_COLLECTION)) { + noPermission(plugin, player); + return; + } + + CreateTaxonomyMenu.openCollection(plugin, player); + return; + } + + if (action.equals(ACTION_FILTER)) { + if (!plugin.adminModes().enabled(player)) { + return; + } + + open(plugin, player, 0, !holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_SEARCH)) { + player.closeInventory(); + player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter collection search text.", NamedTextColor.GOLD), value -> open(plugin, player, 0, holder.customOnly(), value), () -> open(plugin, player, holder.page(), holder.customOnly(), holder.query())), () -> {}); + return; + } + + if (!action.startsWith(ACTION_ENTRY)) { + return; + } + + String id = action.substring(ACTION_ENTRY.length()); + plugin.guis().openSearch(player, new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(id), HeadSort.NAME, SortDirection.ASCENDING, 1, 28, false), SearchMenuState.BackTarget.COLLECTIONS); + } + + private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List entries, @NotNull Map counts, @NotNull Set customIds, int page) { + int fromIndex = page * ENTRY_SLOTS.length; + int toIndex = Math.min(entries.size(), fromIndex + ENTRY_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + HeadCollection entry = entries.get(index); + ItemStack item = entryItem(plugin, player, entry, counts.getOrDefault(entry.id(), 0), customIds.contains(entry.id())); + stamp(plugin, item, ACTION_ENTRY + entry.id()); + inventory.setItem(ENTRY_SLOTS[slotIndex], item); + slotIndex++; + } + } + + private static @NotNull ItemStack entryItem(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadCollection entry, int heads, boolean custom) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("collections")); + item.editMeta(meta -> { + java.util.List lore = new java.util.ArrayList<>(); + if (entry.description() != null && !entry.description().isBlank()) { + lore.add(GuiItems.lore(entry.description(), NamedTextColor.GRAY)); + lore.add(Component.empty()); + } + if (plugin.adminModes().enabled(player)) { + lore.add(GuiItems.idDetail("Heads", heads)); + lore.add(GuiItems.idDetail("ID", entry.id())); + lore.add(GuiItems.idDetail("Type", custom ? "Custom" : "Remote")); + lore.add(Component.empty()); + } + lore.add(GuiItems.lore("Click to browse this collection.", NamedTextColor.GREEN)); + meta.displayName(GuiItems.name(entry.name(), custom ? NamedTextColor.YELLOW : NamedTextColor.GOLD)); + meta.lore(lore); + }); + return item; + } + + private static @NotNull List visibleHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + boolean includeHidden = plugin.adminModes().enabled(player); + return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList(); + } + + private static @NotNull Map counts(@NotNull List heads) { + Map counts = new HashMap<>(); + for (Head head : heads) { + for (String value : head.collections()) { + counts.merge(value, 1, Integer::sum); + } + } + return Map.copyOf(counts); + } + + private static @NotNull Set customIds(@NotNull HeadDBPlugin plugin) { + Set ids = new HashSet<>(); + for (CustomTaxonomyEntry entry : plugin.customCollections().list()) { + ids.add(entry.id()); + } + return Set.copyOf(ids); + } + + private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int entries, int page, int pages, boolean customOnly, @NotNull String query) { + ItemStack back = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("back")); + stamp(plugin, back, ACTION_BACK); + inventory.setItem(SLOT_BACK, back); + + if (page > 0) { + ItemStack previous = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("previous")); + stamp(plugin, previous, ACTION_PREVIOUS); + inventory.setItem(SLOT_PREVIOUS, previous); + } + + inventory.setItem(SLOT_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of( + GuiItems.idDetail("Collections", entries), + GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)), + GuiItems.idDetail("Filter", customOnly ? "Custom" : "All"), + GuiItems.idDetail("Search", query.isBlank() ? "None" : query) + ))); + + if (page + 1 < pages) { + ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next")); + stamp(plugin, next, ACTION_NEXT); + inventory.setItem(SLOT_NEXT, next); + } + + if (plugin.adminModes().enabled(player)) { + ItemStack filter = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(customOnly ? "taxonomy-filter-custom" : "taxonomy-filter-all")); + stamp(plugin, filter, ACTION_FILTER); + inventory.setItem(plugin.guiConfig().slot("collections.filter", SLOT_FILTER), filter); + } + + ItemStack search = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("taxonomy-search")); + stamp(plugin, search, ACTION_SEARCH); + inventory.setItem(plugin.guiConfig().slot("collections.search", SLOT_SEARCH), search); + + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_COLLECTION)) { + ItemStack create = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-collection")); + stamp(plugin, create, ACTION_CREATE); + inventory.setItem(plugin.guiConfig().slot("collections.create", SLOT_CREATE), create); + } + } + + private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) { + String trimmed = query.trim().toLowerCase(Locale.ROOT); + if (trimmed.isBlank()) { + return true; + } + + String haystack = id.toLowerCase(Locale.ROOT) + " " + name.toLowerCase(Locale.ROOT); + for (String part : trimmed.split("\\s+")) { + if (part.isBlank()) { + continue; + } + if (!haystack.contains(part)) { + return false; + } + } + return true; + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages, boolean customOnly, @NotNull String query) { + String key = customOnly ? "title.collections-custom-page" : "title.collections-page"; + String fallback = customOnly ? "Custom Collections %page%/%pages%" : "Collections %page%/%pages%"; + return plugin.guiConfig().text(key, fallback) + .replace("%page%", String.valueOf(page + 1)) + .replace("%pages%", String.valueOf(Math.max(1, pages))) + .replace("%query%", query.trim()); + } + + private static int pageCount(int entries) { + if (entries <= 0) { + return 1; + } + return (int) Math.ceil((double) entries / (double) ENTRY_SLOTS.length); + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "collections_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class Holder implements InventoryHolder { + private final int page; + private final boolean customOnly; + private final String query; + private Inventory inventory; + + private Holder(int page, boolean customOnly, @NotNull String query) { + this.page = page; + this.customOnly = customOnly; + this.query = Objects.requireNonNull(query, "query"); + } + + private int page() { return page; } + private boolean customOnly() { return customOnly; } + private @NotNull String query() { return query; } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java new file mode 100644 index 0000000..2837cb4 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java @@ -0,0 +1,379 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +public final class CreateCategoryMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_PREVIEW = 4; + private static final int SLOT_NAME = 20; + private static final int SLOT_DESCRIPTION = 22; + private static final int SLOT_MATERIAL = 24; + private static final int SLOT_HEAD_ICON = 29; + private static final int SLOT_PERMISSION = 31; + private static final int SLOT_HEADS = 33; + private static final int SLOT_SAVE_DRAFT = 38; + private static final int SLOT_PUBLISH = 40; + private static final int SLOT_DELETE = 42; + private static final int SLOT_PRICE = 44; + private static final int SLOT_BACK = 45; + private static final Map ACTIVE_DRAFTS = new ConcurrentHashMap<>(); + private static final String ACTION_BACK = "back"; + private static final String ACTION_NAME = "name"; + private static final String ACTION_DESCRIPTION = "description"; + private static final String ACTION_MATERIAL = "material"; + private static final String ACTION_HEAD_ICON = "head-icon"; + private static final String ACTION_PERMISSION = "permission"; + private static final String ACTION_HEADS = "heads"; + private static final String ACTION_SAVE_DRAFT = "save-draft"; + private static final String ACTION_PUBLISH = "publish"; + private static final String ACTION_DELETE = "delete"; + private static final String ACTION_PRICE = "price"; + + private CreateCategoryMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + if (!canUse(plugin, player)) { + noPermission(plugin, player); + return; + } + + String id = createDraft(plugin, player); + ACTIVE_DRAFTS.put(player.getUniqueId(), id); + openExisting(plugin, player, id); + } + + public static void openExisting(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String id) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(id, "id"); + + if (!canUse(plugin, player)) { + noPermission(plugin, player); + return; + } + + Optional existing = plugin.customCategories().find(id); + if (existing.isEmpty()) { + ACTIVE_DRAFTS.remove(player.getUniqueId()); + plugin.guis().openBrowse(player); + return; + } + + ACTIVE_DRAFTS.put(player.getUniqueId(), existing.get().id()); + Holder holder = new Holder(existing.get().id()); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(existing.get().draft() ? "Create Category" : "Edit Category", true)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + render(plugin, inventory, existing.get()); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + action.ifPresent(value -> handleAction(plugin, player, holder, value)); + return true; + } + + public static void handleClose(@NotNull HeadDBPlugin plugin, @NotNull Player player, @Nullable InventoryHolder holder) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + + if (!(holder instanceof Holder createHolder)) { + return; + } + + plugin.customCategories().find(createHolder.id()).ifPresent(plugin.customCategories()::save); + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) { + Optional existing = plugin.customCategories().find(holder.id()); + if (existing.isEmpty()) { + plugin.guis().openBrowse(player); + return; + } + + CustomCategory category = existing.get(); + if (action.equals(ACTION_BACK)) { + plugin.guis().openBrowse(player); + return; + } + + if (action.equals(ACTION_NAME)) { + prompt(plugin, player, category, "Enter the category display name.", value -> category.withName(value)); + return; + } + + if (action.equals(ACTION_DESCRIPTION)) { + prompt(plugin, player, category, "Enter the category description.", value -> category.withDescription(value)); + return; + } + + if (action.equals(ACTION_MATERIAL)) { + prompt(plugin, player, category, "Enter a Bukkit material name for the icon.", value -> category.withMaterial(value)); + return; + } + + if (action.equals(ACTION_HEAD_ICON)) { + prompt(plugin, player, category, "Enter a head id for the icon, or none to clear.", value -> headIcon(plugin, player, category, value)); + return; + } + + if (action.equals(ACTION_PERMISSION)) { + player.sendMessage(Component.text("Permission: ", NamedTextColor.GRAY).append(Component.text(Permissions.category(category.id()), NamedTextColor.GOLD))); + openExisting(plugin, player, category.id()); + return; + } + + if (action.equals(ACTION_HEADS)) { + CategoryMembersMenu.open(plugin, player, category.id(), 0); + return; + } + + if (action.equals(ACTION_SAVE_DRAFT)) { + plugin.customCategories().save(category.withDraft(true)); + player.sendMessage(Component.text("Category draft saved: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD))); + openExisting(plugin, player, category.id()); + return; + } + + if (action.equals(ACTION_PUBLISH)) { + plugin.customCategories().save(category.withDraft(false)); + ACTIVE_DRAFTS.remove(player.getUniqueId()); + plugin.clearSearchCache(); + player.sendMessage(Component.text("Category published: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD))); + openExisting(plugin, player, category.id()); + return; + } + + if (action.equals(ACTION_DELETE)) { + DeleteCategoryConfirmMenu.open(plugin, player, category.id()); + return; + } + + if (action.equals(ACTION_PRICE)) { + promptPrice(plugin, player, category); + } + } + + private static @NotNull CustomCategory headIcon(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category, @NotNull String value) { + String trimmed = value.trim(); + if (trimmed.equalsIgnoreCase("none") || trimmed.equalsIgnoreCase("clear")) { + return category.withMaterial("CHEST"); + } + + HeadId id = trimmed.matches("[1-9][0-9]*") ? HeadId.remote(trimmed) : new HeadId(trimmed); + if (plugin.headRegistry().find(id).isEmpty()) { + player.sendMessage(Component.text("Unknown head: ", NamedTextColor.RED).append(Component.text(id.display(), NamedTextColor.GOLD))); + return category; + } + + return category.withMaterial("HEAD:" + id); + } + + private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category, @NotNull String message, @NotNull java.util.function.Function update) { + player.closeInventory(); + plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> { + try { + CustomCategory updated = update.apply(value).withDraft(true); + plugin.customCategories().save(updated); + openExisting(plugin, player, updated.id()); + } catch (IllegalArgumentException exception) { + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + openExisting(plugin, player, category.id()); + } + }, () -> openExisting(plugin, player, category.id())); + } + + private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull CustomCategory category) { + inventory.setItem(SLOT_PREVIEW, preview(plugin, category)); + inventory.setItem(plugin.guiConfig().slot("create-category.name", SLOT_NAME), actionItem(plugin, "category-name", ACTION_NAME, "Name", category.name())); + inventory.setItem(plugin.guiConfig().slot("create-category.description", SLOT_DESCRIPTION), actionItem(plugin, "category-description", ACTION_DESCRIPTION, "Description", category.description())); + inventory.setItem(plugin.guiConfig().slot("create-category.material", SLOT_MATERIAL), actionItem(plugin, "category-material", ACTION_MATERIAL, "Material", category.material())); + inventory.setItem(plugin.guiConfig().slot("create-category.head-icon", SLOT_HEAD_ICON), actionItem(plugin, "category-head-icon", ACTION_HEAD_ICON, "Head Icon", category.headIcon() ? category.headIconId() : "Not set")); + inventory.setItem(plugin.guiConfig().slot("create-category.permission", SLOT_PERMISSION), actionItem(plugin, "category-permission", ACTION_PERMISSION, "Permission", Permissions.category(category.id()))); + inventory.setItem(plugin.guiConfig().slot("create-category.heads", SLOT_HEADS), actionItem(plugin, "category-view-heads", ACTION_HEADS, "Heads", category.headIds().size())); + inventory.setItem(plugin.guiConfig().slot("create-category.save-draft", SLOT_SAVE_DRAFT), actionOnly(plugin, "category-save-draft", ACTION_SAVE_DRAFT)); + inventory.setItem(plugin.guiConfig().slot("create-category.publish", SLOT_PUBLISH), actionOnly(plugin, "category-publish", ACTION_PUBLISH)); + inventory.setItem(plugin.guiConfig().slot("create-category.delete", SLOT_DELETE), actionOnly(plugin, "category-delete", ACTION_DELETE)); + if (plugin.economy().enabled()) { + inventory.setItem(plugin.guiConfig().slot("create-category.price", SLOT_PRICE), actionOnly(plugin, "category-price", ACTION_PRICE)); + } + inventory.setItem(plugin.guiConfig().slot("create-category.back", SLOT_BACK), actionOnly(plugin, "back", ACTION_BACK)); + } + + private static @NotNull String categoryLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) { + return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN) ? category.name() + " (" + category.id() + ")" : category.name(); + } + + private static @NotNull ItemStack preview(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category) { + ItemStack item; + if (category.headIcon()) { + item = plugin.headRegistry().find(new HeadId(category.headIconId())).map(plugin.itemFactory()::create).orElseGet(() -> GuiItems.item(Material.CHEST, Component.empty(), List.of())); + } else { + item = GuiItems.item(GuiMaterials.itemOr(category.material(), Material.CHEST), Component.empty(), List.of()); + } + + item.editMeta(meta -> { + meta.displayName(GuiItems.name(category.draft() ? "DRAFT - " + category.name() : category.name(), category.draft() ? NamedTextColor.YELLOW : NamedTextColor.GOLD)); + meta.lore(List.of(GuiItems.idDetail("ID", category.id()), GuiItems.idDetail("Heads", category.headIds().size()), GuiItems.idDetail("Permission", Permissions.category(category.id())))); + }); + return item; + } + + private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail(key, value), GuiItems.lore("Click to edit.", NamedTextColor.GREEN))); + stamp(plugin, item, action); + return item; + } + + private static @NotNull ItemStack actionOnly(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey)); + stamp(plugin, item, action); + return item; + } + + private static @NotNull String createDraft(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + String id = plugin.customCategories().nextId(); + CustomCategory category = new CustomCategory(id, "Draft Category " + id, "CHEST", "Local custom category.", true, Set.of()); + plugin.customCategories().save(category); + return category.id(); + } + + private static void promptPrice(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) { + if (!plugin.economy().enabled()) { + openExisting(plugin, player, category.id()); + return; + } + + player.closeInventory(); + plugin.prompts().request(player, Component.text("Enter the new category price, or 0 to clear.", NamedTextColor.GOLD), value -> { + try { + double price = Double.parseDouble(value.trim()); + plugin.economy().setCustomCategoryPrice(category.id(), price); + player.sendMessage(plugin.messages().priceUpdated(player, categoryLabel(plugin, player, category), plugin.economy().format(price))); + } catch (NumberFormatException exception) { + player.sendMessage(plugin.messages().priceInvalid(player)); + } catch (RuntimeException exception) { + player.sendMessage(Component.text("Failed to update category price: " + exception.getMessage(), NamedTextColor.RED)); + } + + openExisting(plugin, player, category.id()); + }, () -> openExisting(plugin, player, category.id())); + } + + private static boolean canUse(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + return plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN); + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "create_category_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class Holder implements InventoryHolder { + private final String id; + private Inventory inventory; + + private Holder(@NotNull String id) { + this.id = Objects.requireNonNull(id, "id"); + } + + private @NotNull String id() { + return id; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java new file mode 100644 index 0000000..98ed515 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java @@ -0,0 +1,704 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.HeadCategory; +import io.github.silentdevelopment.headdb.model.HeadCollection; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.model.HeadTag; +import io.github.silentdevelopment.headdb.model.HeadTexture; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.gui.edit.HeadEditMenu; +import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; +import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead; +import io.github.silentdevelopment.headdb.paper.local.texture.TextureInputParser; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +public final class CreateHeadMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_PREVIEW = 4; + private static final int SLOT_USE_HELD = 12; + private static final int SLOT_START_FRESH = 14; + private static final int SLOT_ID = 20; + private static final int SLOT_NAME = 22; + private static final int SLOT_TEXTURE = 24; + private static final int SLOT_CATEGORY = 29; + private static final int SLOT_TAGS = 31; + private static final int SLOT_COLLECTIONS = 33; + private static final int SLOT_CREATE = 40; + private static final int SLOT_BACK = 45; + private static final int SLOT_SELECTOR_BACK = 45; + private static final int SLOT_SELECTOR_PREVIOUS = 48; + private static final int SLOT_SELECTOR_INFO = 49; + private static final int SLOT_SELECTOR_NEXT = 50; + private static final int SLOT_SELECTOR_CLEAR = 53; + private static final Map DRAFTS = new ConcurrentHashMap<>(); + private static final String ACTION_BACK = "back"; + private static final String ACTION_USE_HELD = "use-held"; + private static final String ACTION_START_FRESH = "start-fresh"; + private static final String ACTION_ID = "id"; + private static final String ACTION_NAME = "name"; + private static final String ACTION_TEXTURE = "texture"; + private static final String ACTION_CATEGORY = "category"; + private static final String ACTION_TAGS = "tags"; + private static final String ACTION_COLLECTIONS = "collections"; + private static final String ACTION_CREATE = "create"; + private static final String ACTION_SELECTOR_BACK = "selector-back"; + private static final String ACTION_SELECTOR_PREVIOUS = "selector-previous"; + private static final String ACTION_SELECTOR_NEXT = "selector-next"; + private static final String ACTION_SELECTOR_CLEAR = "selector-clear"; + private static final String ACTION_SELECTOR_ENTRY = "selector-entry:"; + private static final int[] SELECTOR_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private CreateHeadMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void openNew(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + DRAFTS.put(player.getUniqueId(), Draft.empty()); + open(plugin, player); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) { + noPermission(plugin, player); + return; + } + + Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty()); + CreateHeadHolder holder = new CreateHeadHolder(); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text("title.create-head", "Create Head"), true)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + render(plugin, inventory, draft); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + InventoryHolder holder = event.getView().getTopInventory().getHolder(); + if (!(holder instanceof CreateHeadHolder) && !(holder instanceof CreateHeadSelectorHolder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + if (holder instanceof CreateHeadSelectorHolder selectorHolder) { + handleSelectorAction(plugin, player, selectorHolder, action.get()); + return true; + } + + handleAction(plugin, player, action.get()); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String action) { + Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty()); + + if (action.equals(ACTION_BACK)) { + plugin.guis().openBrowse(player); + return; + } + + if (action.equals(ACTION_START_FRESH)) { + DRAFTS.put(player.getUniqueId(), Draft.empty()); + open(plugin, player); + return; + } + + if (action.equals(ACTION_USE_HELD)) { + useHeld(plugin, player, draft); + return; + } + + if (action.equals(ACTION_ID)) { + prompt(plugin, player, "Enter the custom head id.", value -> update(player, draft.withId(value))); + return; + } + + if (action.equals(ACTION_NAME)) { + prompt(plugin, player, "Enter the custom head name.", value -> update(player, draft.withName(value))); + return; + } + + if (action.equals(ACTION_TEXTURE)) { + prompt(plugin, player, "Enter a texture hash, URL, or base64 value.", value -> { + try { + HeadTexture texture = new TextureInputParser().parse(value); + update(player, draft.withTexture(texture.hash())); + } catch (IllegalArgumentException exception) { + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + } + }); + return; + } + + if (action.equals(ACTION_CATEGORY)) { + openSelector(plugin, player, SelectorMode.CATEGORY, 0); + return; + } + + if (action.equals(ACTION_TAGS)) { + openSelector(plugin, player, SelectorMode.TAGS, 0); + return; + } + + if (action.equals(ACTION_COLLECTIONS)) { + openSelector(plugin, player, SelectorMode.COLLECTIONS, 0); + return; + } + + if (action.equals(ACTION_CREATE)) { + saveDraft(plugin, player, draft, true); + } + } + + + private static void openSelector(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull SelectorMode mode, int requestedPage) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(mode, "mode"); + + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) { + noPermission(plugin, player); + return; + } + + Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty()); + List entries = selectorEntries(plugin, player, draft, mode); + int pages = pageCount(entries.size(), SELECTOR_SLOTS.length); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + CreateHeadSelectorHolder holder = new CreateHeadSelectorHolder(mode, page); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(mode.title(), true)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderSelectorEntries(plugin, inventory, draft, mode, entries, page); + renderSelectorControls(plugin, inventory, draft, mode, entries.size(), page, pages); + player.openInventory(inventory); + } + + private static void renderSelectorEntries(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull SelectorMode mode, @NotNull List entries, int page) { + int fromIndex = page * SELECTOR_SLOTS.length; + int toIndex = Math.min(entries.size(), fromIndex + SELECTOR_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + SelectionEntry entry = entries.get(index); + ItemStack item = selectorItem(plugin, entry, isSelected(draft, mode, entry.id())); + stamp(plugin, item, ACTION_SELECTOR_ENTRY + entry.id()); + inventory.setItem(SELECTOR_SLOTS[slotIndex], item); + slotIndex++; + } + } + + private static void renderSelectorControls(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull SelectorMode mode, int entries, int page, int pages) { + setAction(plugin, inventory, SLOT_SELECTOR_BACK, "back", ACTION_SELECTOR_BACK); + + if (page > 0) { + setAction(plugin, inventory, SLOT_SELECTOR_PREVIOUS, "previous", ACTION_SELECTOR_PREVIOUS); + } + + inventory.setItem(SLOT_SELECTOR_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name(mode.title(), NamedTextColor.GOLD), List.of(GuiItems.idDetail("Entries", entries), GuiItems.idDetail("Selected", selectedCount(draft, mode)), GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages))))); + + if (page + 1 < pages) { + setAction(plugin, inventory, SLOT_SELECTOR_NEXT, "next", ACTION_SELECTOR_NEXT); + } + + if (selectedCount(draft, mode) > 0 || mode == SelectorMode.CATEGORY) { + setAction(plugin, inventory, SLOT_SELECTOR_CLEAR, "clear-filters", ACTION_SELECTOR_CLEAR); + } + } + + private static @NotNull ItemStack selectorItem(@NotNull HeadDBPlugin plugin, @NotNull SelectionEntry entry, boolean selected) { + String iconKey = selected ? "filter-selected" : "filter-unselected"; + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.name(entry.name(), selected ? NamedTextColor.GREEN : NamedTextColor.GOLD), List.of(GuiItems.idDetail("ID", entry.id()), GuiItems.lore(selected ? "Selected. Click to remove." : "Click to select.", selected ? NamedTextColor.GREEN : NamedTextColor.GRAY))); + return item; + } + + private static void handleSelectorAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CreateHeadSelectorHolder holder, @NotNull String action) { + if (action.equals(ACTION_SELECTOR_BACK)) { + open(plugin, player); + return; + } + + if (action.equals(ACTION_SELECTOR_PREVIOUS)) { + openSelector(plugin, player, holder.mode(), holder.page() - 1); + return; + } + + if (action.equals(ACTION_SELECTOR_NEXT)) { + openSelector(plugin, player, holder.mode(), holder.page() + 1); + return; + } + + Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty()); + if (action.equals(ACTION_SELECTOR_CLEAR)) { + DRAFTS.put(player.getUniqueId(), clearSelection(draft, holder.mode())); + openSelector(plugin, player, holder.mode(), holder.page()); + return; + } + + if (!action.startsWith(ACTION_SELECTOR_ENTRY)) { + return; + } + + String id = action.substring(ACTION_SELECTOR_ENTRY.length()); + DRAFTS.put(player.getUniqueId(), toggleSelection(draft, holder.mode(), id)); + if (holder.mode() == SelectorMode.CATEGORY) { + open(plugin, player); + return; + } + + openSelector(plugin, player, holder.mode(), holder.page()); + } + + private static @NotNull Draft toggleSelection(@NotNull Draft draft, @NotNull SelectorMode mode, @NotNull String id) { + return switch (mode) { + case CATEGORY -> draft.withCategory(id); + case TAGS -> draft.withTags(toggle(draft.tags(), id)); + case COLLECTIONS -> draft.withCollections(toggle(draft.collections(), id)); + }; + } + + private static @NotNull Draft clearSelection(@NotNull Draft draft, @NotNull SelectorMode mode) { + return switch (mode) { + case CATEGORY -> draft.withCategory("custom"); + case TAGS -> draft.withTags(Set.of()); + case COLLECTIONS -> draft.withCollections(Set.of()); + }; + } + + private static @NotNull Set toggle(@NotNull Set current, @NotNull String id) { + LinkedHashSet updated = new LinkedHashSet<>(current); + if (updated.contains(id)) { + updated.remove(id); + } else { + updated.add(id); + } + return Set.copyOf(updated); + } + + private static boolean isSelected(@NotNull Draft draft, @NotNull SelectorMode mode, @NotNull String id) { + return switch (mode) { + case CATEGORY -> draft.category().equals(id); + case TAGS -> draft.tags().contains(id); + case COLLECTIONS -> draft.collections().contains(id); + }; + } + + private static int selectedCount(@NotNull Draft draft, @NotNull SelectorMode mode) { + return switch (mode) { + case CATEGORY -> draft.category().isBlank() ? 0 : 1; + case TAGS -> draft.tags().size(); + case COLLECTIONS -> draft.collections().size(); + }; + } + + private static @NotNull List selectorEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft, @NotNull SelectorMode mode) { + Map entries = new java.util.LinkedHashMap<>(); + + switch (mode) { + case CATEGORY -> { + entries.put("custom", new SelectionEntry("custom", "Custom")); + plugin.headRegistry().categories().stream() + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)) + .forEach(category -> entries.put(category.id(), new SelectionEntry(category.id(), category.name()))); + boolean adminMode = plugin.adminModes().enabled(player); + plugin.customCategories().listVisible(adminMode).stream() + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER)) + .forEach(category -> entries.put(category.id(), new SelectionEntry(category.id(), category.draft() ? "DRAFT - " + category.name() : category.name()))); + if (!draft.category().isBlank()) { + entries.putIfAbsent(draft.category(), new SelectionEntry(draft.category(), displayName(draft.category()))); + } + } + case TAGS -> { + entries.put("custom", new SelectionEntry("custom", "Custom")); + plugin.headRegistry().tags().stream().sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER)).forEach(tag -> entries.put(tag.id(), new SelectionEntry(tag.id(), tag.name()))); + for (String tag : draft.tags()) { + entries.putIfAbsent(tag, new SelectionEntry(tag, displayName(tag))); + } + } + case COLLECTIONS -> { + plugin.headRegistry().collections().stream().sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)).forEach(collection -> entries.put(collection.id(), new SelectionEntry(collection.id(), collection.name()))); + for (String collection : draft.collections()) { + entries.putIfAbsent(collection, new SelectionEntry(collection, displayName(collection))); + } + } + } + + return List.copyOf(entries.values()); + } + + private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft) { + inventory.setItem(slot(plugin, "create-head.preview", SLOT_PREVIEW), preview(plugin, draft)); + setAction(plugin, inventory, slot(plugin, "create-head.use-held", SLOT_USE_HELD), "create-head-held", ACTION_USE_HELD); + setAction(plugin, inventory, slot(plugin, "create-head.reset", SLOT_START_FRESH), "create-head-fresh", ACTION_START_FRESH); + inventory.setItem(slot(plugin, "create-head.id", SLOT_ID), actionItem(plugin, "create-head-id", ACTION_ID, "ID", draft.id().isBlank() ? "Not set" : draft.id())); + inventory.setItem(slot(plugin, "create-head.name", SLOT_NAME), actionItem(plugin, "create-head-name", ACTION_NAME, "Name", draft.name().isBlank() ? "Not set" : draft.name())); + inventory.setItem(slot(plugin, "create-head.texture", SLOT_TEXTURE), actionItem(plugin, "create-head-texture", ACTION_TEXTURE, "Texture", draft.texture().isBlank() ? "Not set" : "set")); + inventory.setItem(slot(plugin, "create-head.category", SLOT_CATEGORY), actionItem(plugin, "create-head-category", ACTION_CATEGORY, "Category", draft.category().isBlank() ? "custom" : draft.category())); + inventory.setItem(slot(plugin, "create-head.tags", SLOT_TAGS), actionItem(plugin, "create-head-tags", ACTION_TAGS, "Tags", draft.tags().isEmpty() ? "none" : draft.tags().size())); + inventory.setItem(slot(plugin, "create-head.collections", SLOT_COLLECTIONS), actionItem(plugin, "create-head-collections", ACTION_COLLECTIONS, "Collections", draft.collections().isEmpty() ? "none" : draft.collections().size())); + setAction(plugin, inventory, slot(plugin, "create-head.save", SLOT_CREATE), "create-head-save", ACTION_CREATE); + setAction(plugin, inventory, slot(plugin, "create-head.back", SLOT_BACK), "back", ACTION_BACK); + } + + private static int slot(@NotNull HeadDBPlugin plugin, @NotNull String key, int fallback) { + return plugin.guiConfig().slot(key, fallback); + } + + private static @NotNull ItemStack preview(@NotNull HeadDBPlugin plugin, @NotNull Draft draft) { + ItemStack item = null; + if (!draft.texture().isBlank()) { + try { + String previewId = draft.id().trim().isBlank() ? "preview" : draft.id().trim(); + String previewName = draft.name().trim().isBlank() ? displayName(previewId) : draft.name().trim(); + String previewCategory = draft.category().trim().isBlank() ? "custom" : draft.category().trim(); + StoredCustomHead head = new StoredCustomHead(previewId, previewName, draft.texture().trim(), null, List.of(), draft.tags(), draft.collections(), previewCategory, Instant.now(), Instant.now(), null, true); + item = plugin.itemFactory().create(head.toHead()); + } catch (IllegalArgumentException ignored) { + item = null; + } + } + + if (item == null) { + item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-head")); + } + + item.editMeta(meta -> { + List lore = meta.lore() == null ? new ArrayList<>() : new ArrayList<>(meta.lore()); + if (!lore.isEmpty()) { + lore.add(Component.empty()); + } + lore.add(GuiItems.idDetail("ID", draft.id().isBlank() ? "Not set" : "custom:" + draft.id())); + lore.add(GuiItems.idDetail("Name", draft.name().isBlank() ? "Not set" : draft.name())); + lore.add(GuiItems.idDetail("Category", draft.category().isBlank() ? "custom" : draft.category())); + lore.add(GuiItems.idDetail("Texture", draft.texture().isBlank() ? "Not set" : "set")); + meta.lore(lore); + }); + return item; + } + + private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) { + java.util.List lore = new java.util.ArrayList<>(GuiItems.miniLore(plugin.guiConfig().icon(iconKey).lore())); + if (!lore.isEmpty()) { + lore.add(Component.empty()); + } + lore.add(GuiItems.idDetail("Current", value)); + lore.add(GuiItems.lore("Click to edit.", NamedTextColor.GREEN)); + + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), lore); + stamp(plugin, item, action); + return item; + } + + private static void setAction(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, int slot, @NotNull String iconKey, @NotNull String action) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey)); + stamp(plugin, item, action); + inventory.setItem(slot, item); + } + + private static void useHeld(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft) { + ItemStack held = player.getInventory().getItemInMainHand(); + Optional headId = HeadItemIds.read(plugin, held); + if (headId.isPresent()) { + Optional head = plugin.headRegistry().find(headId.get()); + if (head.isPresent()) { + Draft updated = draft.withTexture(head.get().texture().hash()); + if (draft.id().isBlank() && !headId.get().isPlayer()) { + updated = updated.withId(headId.get().key()); + } + if (draft.name().isBlank()) { + updated = updated.withName(head.get().name()); + } + DRAFTS.put(player.getUniqueId(), updated); + player.getScheduler().run(plugin, task -> open(plugin, player), () -> {}); + return; + } + } + + try { + HeadTexture texture = new TextureInputParser().fromItem(held); + DRAFTS.put(player.getUniqueId(), draft.withTexture(texture.hash())); + player.getScheduler().run(plugin, task -> open(plugin, player), () -> {}); + } catch (IllegalArgumentException exception) { + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + } + } + + public static void handleClose(@NotNull HeadDBPlugin plugin, @NotNull Player player, @Nullable InventoryHolder holder) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + + if (!(holder instanceof CreateHeadHolder) && !(holder instanceof CreateHeadSelectorHolder)) { + return; + } + + Draft draft = DRAFTS.get(player.getUniqueId()); + if (draft == null || !draft.canPersist()) { + return; + } + + saveDraft(plugin, player, draft, false); + } + + private static void saveDraft(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft, boolean reopenEdit) { + if (!Permissions.has(player, Permissions.CUSTOM_CREATE)) { + noPermission(plugin, player); + return; + } + + try { + StoredCustomHead head = draft.toStored(player.getUniqueId()).withDraft(true); + plugin.headRegistry().customHeads().save(head); + plugin.headRegistry().onLocalMutation(); + plugin.clearItemCache(); + plugin.clearSearchCache(); + if (!reopenEdit) { + return; + } + + DRAFTS.remove(player.getUniqueId()); + player.sendMessage(Component.text("Saved draft head: ", NamedTextColor.GRAY).append(Component.text(head.name() + " (" + head.headId().display() + ")", NamedTextColor.GOLD))); + HeadEditMenu.open(plugin, player, HeadId.custom(head.id())); + } catch (IllegalArgumentException exception) { + if (!reopenEdit) { + return; + } + + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + open(plugin, player); + } + } + + private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String message, @NotNull Consumer input) { + player.closeInventory(); + player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> { + input.accept(value); + open(plugin, player); + }, () -> open(plugin, player)), () -> {}); + } + + private static void update(@NotNull Player player, @NotNull Draft draft) { + DRAFTS.put(player.getUniqueId(), draft); + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static int pageCount(int entries, int pageSize) { + if (entries <= 0) { + return 1; + } + return (int) Math.ceil((double) entries / (double) pageSize); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "create_head_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private record SelectionEntry(@NotNull String id, @NotNull String name) {} + + private enum SelectorMode { + CATEGORY("Select Category"), + TAGS("Select Tags"), + COLLECTIONS("Select Collections"); + + private final String title; + + SelectorMode(@NotNull String title) { + this.title = Objects.requireNonNull(title, "title"); + } + + private @NotNull String title() { + return title; + } + } + + private record Draft(@NotNull String id, @NotNull String name, @NotNull String texture, @NotNull String category, @NotNull Set tags, @NotNull Set collections) { + private static @NotNull Draft empty() { + return new Draft("", "", "", "custom", Set.of("custom"), Set.of()); + } + + private @NotNull Draft withId(@NotNull String id) { return new Draft(id, name, texture, category, tags, collections); } + private @NotNull Draft withName(@NotNull String name) { return new Draft(id, name, texture, category, tags, collections); } + private @NotNull Draft withTexture(@NotNull String texture) { return new Draft(id, name, texture, category, tags, collections); } + private @NotNull Draft withCategory(@NotNull String category) { return new Draft(id, name, texture, category, tags, collections); } + private @NotNull Draft withTags(@NotNull Set tags) { return new Draft(id, name, texture, category, tags, collections); } + private @NotNull Draft withCollections(@NotNull Set collections) { return new Draft(id, name, texture, category, tags, collections); } + + private boolean canPersist() { + return !id.trim().isBlank() && !texture.trim().isBlank(); + } + + private @NotNull StoredCustomHead toStored(@NotNull UUID createdBy) { + String cleanId = id.trim(); + if (cleanId.isBlank()) { + throw new IllegalArgumentException("ID is required."); + } + + if (texture.trim().isBlank()) { + throw new IllegalArgumentException("Texture is required."); + } + + String cleanName = name.trim().isBlank() ? displayName(cleanId) : name.trim(); + String cleanCategory = category.trim().isBlank() ? "custom" : category.trim(); + return new StoredCustomHead(cleanId, cleanName, texture.trim(), null, List.of(), tags, collections, cleanCategory, Instant.now(), Instant.now(), createdBy); + } + } + + private static @NotNull String displayName(@NotNull String id) { + String normalized = id.trim().replace('_', '-'); + if (normalized.isBlank()) { + return "Custom Head"; + } + + String[] parts = normalized.split("-"); + java.util.List words = new java.util.ArrayList<>(); + for (String part : parts) { + if (part.isBlank()) { + continue; + } + words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1)); + } + return words.isEmpty() ? normalized : String.join(" ", words); + } + + private static final class CreateHeadSelectorHolder implements InventoryHolder { + private final SelectorMode mode; + private final int page; + private Inventory inventory; + + private CreateHeadSelectorHolder(@NotNull SelectorMode mode, int page) { + this.mode = Objects.requireNonNull(mode, "mode"); + this.page = page; + } + + private @NotNull SelectorMode mode() { + return mode; + } + + private int page() { + return page; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } + + private static final class CreateHeadHolder implements InventoryHolder { + private Inventory inventory; + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java new file mode 100644 index 0000000..3c6d9a0 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java @@ -0,0 +1,310 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +public final class CreateTaxonomyMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_PREVIEW = 4; + private static final int SLOT_ID = 20; + private static final int SLOT_NAME = 22; + private static final int SLOT_DESCRIPTION = 24; + private static final int SLOT_SAVE = 40; + private static final int SLOT_BACK = 45; + private static final String ACTION_BACK = "back"; + private static final String ACTION_ID = "id"; + private static final String ACTION_NAME = "name"; + private static final String ACTION_DESCRIPTION = "description"; + private static final String ACTION_SAVE = "save"; + private static final Map DRAFTS = new ConcurrentHashMap<>(); + + private CreateTaxonomyMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void openTag(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + Objects.requireNonNull(player, "player"); + DRAFTS.put(new DraftKey(player.getUniqueId(), Mode.TAG), Draft.empty(Mode.TAG)); + open(plugin, player, Mode.TAG); + } + + public static void openCollection(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + Objects.requireNonNull(player, "player"); + DRAFTS.put(new DraftKey(player.getUniqueId(), Mode.COLLECTION), Draft.empty(Mode.COLLECTION)); + open(plugin, player, Mode.COLLECTION); + } + + private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(mode, "mode"); + + if (!canUse(plugin, player, mode)) { + noPermission(plugin, player); + return; + } + + Draft draft = DRAFTS.computeIfAbsent(new DraftKey(player.getUniqueId(), mode), ignored -> Draft.empty(mode)); + Holder holder = new Holder(mode); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text(mode.titleKey(), mode.titleFallback()), true)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + render(plugin, inventory, draft, mode); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + action.ifPresent(value -> handleAction(plugin, player, holder.mode(), value)); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull String action) { + DraftKey key = new DraftKey(player.getUniqueId(), mode); + Draft draft = DRAFTS.computeIfAbsent(key, ignored -> Draft.empty(mode)); + + if (action.equals(ACTION_BACK)) { + back(plugin, player, mode); + return; + } + + if (action.equals(ACTION_ID)) { + prompt(plugin, player, mode, "Enter the " + mode.displayName() + " id.", value -> draft.withId(value)); + return; + } + + if (action.equals(ACTION_NAME)) { + prompt(plugin, player, mode, "Enter the " + mode.displayName() + " display name.", value -> draft.withName(value)); + return; + } + + if (action.equals(ACTION_DESCRIPTION)) { + prompt(plugin, player, mode, "Enter the " + mode.displayName() + " description.", value -> draft.withDescription(value)); + return; + } + + if (action.equals(ACTION_SAVE)) { + save(plugin, player, mode, key, draft); + } + } + + private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull String message, @NotNull Function update) { + player.closeInventory(); + plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> { + DRAFTS.put(new DraftKey(player.getUniqueId(), mode), update.apply(value)); + open(plugin, player, mode); + }, () -> open(plugin, player, mode)); + } + + private static void save(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull DraftKey key, @NotNull Draft draft) { + if (!canUse(plugin, player, mode)) { + noPermission(plugin, player); + return; + } + + try { + CustomTaxonomyEntry entry = new CustomTaxonomyEntry(draft.id(), draft.name(), draft.description(), player.getUniqueId()); + switch (mode) { + case TAG -> plugin.customTags().save(entry); + case COLLECTION -> plugin.customCollections().save(entry); + } + DRAFTS.remove(key); + plugin.headRegistry().onLocalMutation(); + plugin.clearSearchCache(); + player.sendMessage(plugin.messages().taxonomyCreated(player, mode.displayName().toLowerCase(java.util.Locale.ROOT), entry.name(), entry.id())); + back(plugin, player, mode); + } catch (IllegalArgumentException exception) { + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + open(plugin, player, mode); + } + } + + private static void back(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) { + switch (mode) { + case TAG -> TagsMenu.open(plugin, player); + case COLLECTION -> CollectionsMenu.open(plugin, player); + } + } + + private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull Mode mode) { + inventory.setItem(SLOT_PREVIEW, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(mode.iconKey()), GuiItems.name(mode.titleFallback(), NamedTextColor.GOLD), List.of(GuiItems.idDetail("ID", valueOrUnset(draft.id())), GuiItems.idDetail("Name", valueOrUnset(draft.name()))))); + inventory.setItem(plugin.guiConfig().slot("taxonomy.id", SLOT_ID), actionItem(plugin, "taxonomy-id", ACTION_ID, "ID", valueOrUnset(draft.id()))); + inventory.setItem(plugin.guiConfig().slot("taxonomy.name", SLOT_NAME), actionItem(plugin, "taxonomy-name", ACTION_NAME, "Name", valueOrUnset(draft.name()))); + inventory.setItem(plugin.guiConfig().slot("taxonomy.description", SLOT_DESCRIPTION), actionItem(plugin, "taxonomy-description", ACTION_DESCRIPTION, "Description", valueOrUnset(draft.description()))); + inventory.setItem(plugin.guiConfig().slot("taxonomy.save", SLOT_SAVE), actionOnly(plugin, "taxonomy-save", ACTION_SAVE)); + inventory.setItem(plugin.guiConfig().slot("taxonomy.back", SLOT_BACK), actionOnly(plugin, "back", ACTION_BACK)); + } + + private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail(key, value), GuiItems.lore("Click to edit.", NamedTextColor.GREEN))); + stamp(plugin, item, action); + return item; + } + + private static @NotNull ItemStack actionOnly(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey)); + stamp(plugin, item, action); + return item; + } + + private static boolean canUse(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) { + return plugin.adminModes().enabled(player) && Permissions.has(player, mode.permission()); + } + + private static @NotNull String valueOrUnset(@NotNull String value) { + return value.isBlank() ? "Not set" : value; + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "taxonomy_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private enum Mode { + TAG("tag", "Tag", "create-tag", "title.create-tag", "Create Tag", Permissions.GUI_CREATE_TAG), + COLLECTION("collection", "Collection", "create-collection", "title.create-collection", "Create Collection", Permissions.GUI_CREATE_COLLECTION); + + private final String id; + private final String displayName; + private final String iconKey; + private final String titleKey; + private final String titleFallback; + private final String permission; + + Mode(@NotNull String id, @NotNull String displayName, @NotNull String iconKey, @NotNull String titleKey, @NotNull String titleFallback, @NotNull String permission) { + this.id = Objects.requireNonNull(id, "id"); + this.displayName = Objects.requireNonNull(displayName, "displayName"); + this.iconKey = Objects.requireNonNull(iconKey, "iconKey"); + this.titleKey = Objects.requireNonNull(titleKey, "titleKey"); + this.titleFallback = Objects.requireNonNull(titleFallback, "titleFallback"); + this.permission = Objects.requireNonNull(permission, "permission"); + } + + private @NotNull String displayName() { return displayName; } + private @NotNull String iconKey() { return iconKey; } + private @NotNull String titleKey() { return titleKey; } + private @NotNull String titleFallback() { return titleFallback; } + private @NotNull String permission() { return permission; } + } + + private record Draft(@NotNull String id, @NotNull String name, @NotNull String description) { + private static @NotNull Draft empty(@NotNull Mode mode) { + return new Draft("", "", "Local custom " + mode.displayName().toLowerCase(java.util.Locale.ROOT) + "."); + } + + private @NotNull Draft withId(@NotNull String id) { return new Draft(id, name, description); } + private @NotNull Draft withName(@NotNull String name) { return new Draft(id, name, description); } + private @NotNull Draft withDescription(@NotNull String description) { return new Draft(id, name, description); } + } + + private record DraftKey(@NotNull UUID playerId, @NotNull Mode mode) {} + + private static final class Holder implements InventoryHolder { + private final Mode mode; + private Inventory inventory; + + private Holder(@NotNull Mode mode) { + this.mode = Objects.requireNonNull(mode, "mode"); + } + + private @NotNull Mode mode() { + return mode; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java index 7edb53b..286d319 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java @@ -1,5 +1,6 @@ package io.github.silentdevelopment.headdb.paper.gui.category; +import io.github.silentdevelopment.headdb.model.HeadCategory; import io.github.silentdevelopment.headdb.model.HeadId; import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; import org.bukkit.Material; @@ -10,21 +11,27 @@ import java.util.Objects; import java.util.Set; -public record CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull Set headIds) { +public record CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull String description, boolean draft, @NotNull Set headIds) { public CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material) { - this(id, name, material, Set.of()); + this(id, name, material, "Local custom category.", false, Set.of()); + } + + public CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull Set headIds) { + this(id, name, material, "Local custom category.", false, headIds); } public CustomCategory { Objects.requireNonNull(id, "id"); Objects.requireNonNull(name, "name"); Objects.requireNonNull(material, "material"); + Objects.requireNonNull(description, "description"); Objects.requireNonNull(headIds, "headIds"); id = id.trim().toLowerCase(Locale.ROOT); name = name.trim(); material = normalizeIcon(material); + description = description.trim().isBlank() ? "Local custom category." : description.trim(); headIds = Set.copyOf(new LinkedHashSet<>(headIds)); if (id.isBlank()) { @@ -48,6 +55,31 @@ public boolean headIcon() { return material.substring("HEAD:".length()); } + public @NotNull CustomCategory withName(@NotNull String name) { + return new CustomCategory(id, name, material, description, draft, headIds); + } + + public @NotNull CustomCategory withMaterial(@NotNull String material) { + return new CustomCategory(id, name, material, description, draft, headIds); + } + + public @NotNull CustomCategory withDescription(@NotNull String description) { + return new CustomCategory(id, name, material, description, draft, headIds); + } + + public @NotNull CustomCategory withDraft(boolean draft) { + return new CustomCategory(id, name, material, description, draft, headIds); + } + + public @NotNull CustomCategory withHeadIds(@NotNull Set headIds) { + return new CustomCategory(id, name, material, description, draft, headIds); + } + + public @NotNull HeadCategory toHeadCategory() { + String displayName = draft ? "[DRAFT] " + name : name; + return new HeadCategory(id, displayName, description); + } + private static @NotNull String normalizeIcon(@NotNull String value) { String trimmed = value.trim(); if (trimmed.toUpperCase(Locale.ROOT).startsWith("HEAD:")) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java index 8a086bd..e783d4d 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java @@ -31,11 +31,11 @@ public CustomCategoryService(@NotNull Path databaseFile) { public synchronized @NotNull List list() { List categories = new ArrayList<>(); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material FROM headdb_custom_categories ORDER BY lower(name) ASC")) { + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories ORDER BY lower(name) ASC")) { try (ResultSet result = statement.executeQuery()) { while (result.next()) { String id = result.getString("id"); - categories.add(new CustomCategory(id, result.getString("name"), result.getString("material"), headIds(connection, id))); + categories.add(categoryFromRow(result, headIds(connection, id))); } } } catch (SQLException exception) { @@ -46,6 +46,25 @@ public CustomCategoryService(@NotNull Path databaseFile) { return List.copyOf(categories); } + + public synchronized @NotNull List listPublished() { + return list().stream().filter(category -> !category.draft()).toList(); + } + + public synchronized @NotNull List listVisible(boolean includeDrafts) { + if (includeDrafts) { + return list(); + } + + return listPublished(); + } + + private static @NotNull CustomCategory categoryFromRow(@NotNull ResultSet result, @NotNull Set headIds) throws SQLException { + String description = result.getString("description"); + boolean draft = result.getBoolean("draft"); + return new CustomCategory(result.getString("id"), result.getString("name"), result.getString("material"), description == null ? "Local custom category." : description, draft, headIds); + } + public synchronized @NotNull String nextId() { int next = 1; try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id FROM headdb_custom_categories")) { @@ -73,14 +92,14 @@ public CustomCategoryService(@NotNull Path databaseFile) { Objects.requireNonNull(id, "id"); String normalized = normalize(id); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material FROM headdb_custom_categories WHERE id = ?")) { + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories WHERE id = ?")) { statement.setString(1, normalized); try (ResultSet result = statement.executeQuery()) { if (!result.next()) { return Optional.empty(); } - return Optional.of(new CustomCategory(result.getString("id"), result.getString("name"), result.getString("material"), headIds(connection, normalized))); + return Optional.of(categoryFromRow(result, headIds(connection, normalized))); } } catch (SQLException exception) { throw new IllegalStateException("Failed to read HeadDB custom category " + normalized + ".", exception); @@ -92,10 +111,12 @@ public synchronized void save(@NotNull CustomCategory category) { try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); - try (PreparedStatement statement = connection.prepareStatement("INSERT INTO headdb_custom_categories(id, name, material) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, material = excluded.material")) { + try (PreparedStatement statement = connection.prepareStatement("INSERT INTO headdb_custom_categories(id, name, material, description, draft) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, material = excluded.material, description = excluded.description, draft = excluded.draft")) { statement.setString(1, category.id()); statement.setString(2, category.name()); statement.setString(3, category.material()); + statement.setString(4, category.description()); + statement.setBoolean(5, category.draft()); statement.executeUpdate(); } @@ -124,7 +145,7 @@ public synchronized void addHead(@NotNull String categoryId, @NotNull HeadId hea CustomCategory category = find(categoryId).orElseThrow(() -> new IllegalArgumentException("Unknown custom category: " + categoryId)); LinkedHashSet ids = new LinkedHashSet<>(category.headIds()); ids.add(headId); - save(new CustomCategory(category.id(), category.name(), category.material(), ids)); + save(category.withHeadIds(ids)); } public synchronized void removeHead(@NotNull String categoryId, @NotNull HeadId headId) { @@ -132,7 +153,7 @@ public synchronized void removeHead(@NotNull String categoryId, @NotNull HeadId CustomCategory category = find(categoryId).orElseThrow(() -> new IllegalArgumentException("Unknown custom category: " + categoryId)); LinkedHashSet ids = new LinkedHashSet<>(category.headIds()); ids.remove(headId); - save(new CustomCategory(category.id(), category.name(), category.material(), ids)); + save(category.withHeadIds(ids)); } public synchronized boolean delete(@NotNull String id) { @@ -177,9 +198,11 @@ public synchronized boolean delete(@NotNull String id) { private void createSchema() { try (Connection connection = dataSource.getConnection()) { - try (PreparedStatement categories = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_categories (id TEXT PRIMARY KEY, name TEXT NOT NULL, material TEXT NOT NULL)")) { + try (PreparedStatement categories = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_categories (id TEXT PRIMARY KEY, name TEXT NOT NULL, material TEXT NOT NULL, description TEXT NOT NULL DEFAULT 'Local custom category.', draft INTEGER NOT NULL DEFAULT 0)")) { categories.executeUpdate(); } + addColumnIfMissing(connection, "headdb_custom_categories", "description", "TEXT NOT NULL DEFAULT 'Local custom category.'"); + addColumnIfMissing(connection, "headdb_custom_categories", "draft", "INTEGER NOT NULL DEFAULT 0"); try (PreparedStatement heads = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_category_heads (category_id TEXT NOT NULL, head_id TEXT NOT NULL, PRIMARY KEY (category_id, head_id))")) { heads.executeUpdate(); } @@ -188,6 +211,19 @@ private void createSchema() { } } + + private static void addColumnIfMissing(@NotNull Connection connection, @NotNull String table, @NotNull String column, @NotNull String definition) throws SQLException { + try (ResultSet result = connection.getMetaData().getColumns(null, null, table, column)) { + if (result.next()) { + return; + } + } + + try (PreparedStatement statement = connection.prepareStatement("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition)) { + statement.executeUpdate(); + } + } + private static @NotNull SQLiteDataSource dataSource(@NotNull Path databaseFile) { try { Path parent = databaseFile.toAbsolutePath().normalize().getParent(); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java new file mode 100644 index 0000000..b96248a --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java @@ -0,0 +1,287 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +public final class CustomCategoryViewMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_BACK = 45; + private static final int SLOT_PREVIOUS = 48; + private static final int SLOT_INFO = 49; + private static final int SLOT_NEXT = 50; + private static final String ACTION_BACK = "back"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_NEXT = "next"; + private static final int[] HEAD_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private CustomCategoryViewMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId, int requestedPage) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(categoryId, "categoryId"); + + Optional existing = plugin.customCategories().find(categoryId); + if (existing.isEmpty()) { + plugin.guis().openBrowse(player); + return; + } + + CustomCategory category = existing.get(); + boolean adminMode = plugin.adminModes().enabled(player); + if (category.draft() && !adminMode) { + noPermission(plugin, player); + return; + } + + if (!Permissions.canViewCategory(player, category.id())) { + noPermission(plugin, player); + return; + } + + List heads = heads(plugin, category, adminMode); + int pages = pageCount(heads.size()); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + Holder holder = new Holder(category.id(), page); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(category.draft() ? "DRAFT - " + category.name() : category.name(), adminMode)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderHeads(plugin, player, inventory, heads, page, adminMode); + renderControls(plugin, player, inventory, category, heads.size(), page, pages); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event, @NotNull Consumer edit, @NotNull Consumer give) { + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isPresent()) { + handleAction(plugin, player, holder, action.get()); + return true; + } + + Optional id = HeadItemIds.read(plugin, item); + if (id.isEmpty()) { + return true; + } + + if ((event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP) && plugin.adminModes().enabled(player)) { + edit.accept(id.get()); + return true; + } + + if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) { + if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) { + noPermission(plugin, player); + return true; + } + + plugin.favorites().toggle(player.getUniqueId(), id.get()); + open(plugin, player, holder.categoryId(), holder.page()); + return true; + } + + give.accept(item); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) { + if (action.equals(ACTION_BACK)) { + plugin.guis().openBrowse(player); + return; + } + + if (action.equals(ACTION_PREVIOUS)) { + open(plugin, player, holder.categoryId(), holder.page() - 1); + return; + } + + if (action.equals(ACTION_NEXT)) { + open(plugin, player, holder.categoryId(), holder.page() + 1); + return; + } + } + + private static void renderHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List heads, int page, boolean adminMode) { + int fromIndex = page * HEAD_SLOTS.length; + int toIndex = Math.min(heads.size(), fromIndex + HEAD_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + Head head = heads.get(index); + ItemStack item = plugin.itemFactory().create(head); + plugin.favorites().decorate(player.getUniqueId(), head.id(), item); + if (adminMode) { + item.editMeta(meta -> { + List lore = meta.lore() == null ? new ArrayList<>() : new ArrayList<>(meta.lore()); + lore.add(Component.empty()); + lore.add(GuiItems.idDetail("ID", head.id().display())); + lore.add(GuiItems.metaDetail("Category", head.category())); + lore.add(Component.text("Press ", NamedTextColor.GRAY).append(Component.keybind("key.drop", NamedTextColor.GOLD)).append(Component.text(" to edit.", NamedTextColor.GRAY)).decoration(TextDecoration.ITALIC, false)); + meta.lore(lore); + }); + } + inventory.setItem(HEAD_SLOTS[slotIndex], item); + slotIndex++; + } + } + + private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull CustomCategory category, int totalHeads, int page, int pages) { + inventory.setItem(SLOT_BACK, action(plugin, ACTION_BACK, "back")); + inventory.setItem(SLOT_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of(GuiItems.idDetail("Heads", totalHeads), GuiItems.idDetail("ID", category.id()), GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages))))); + if (page > 0) { + inventory.setItem(SLOT_PREVIOUS, action(plugin, ACTION_PREVIOUS, "previous")); + } + if (page + 1 < pages) { + inventory.setItem(SLOT_NEXT, action(plugin, ACTION_NEXT, "next")); + } + } + + private static @NotNull List heads(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category, boolean adminMode) { + List heads = new ArrayList<>(); + for (HeadId id : category.headIds()) { + Optional head = id.isCustom() && adminMode ? plugin.headRegistry().customHeads().findStored(id).map(stored -> stored.toHead()) : plugin.headRegistry().find(id); + head.ifPresent(heads::add); + } + heads.sort(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(heads); + } + + private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String action, @NotNull String iconKey) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey)); + stamp(plugin, item, action); + return item; + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "custom_category_view_action"); + } + + private static int pageCount(int entries) { + if (entries <= 0) { + return 1; + } + return (int) Math.ceil((double) entries / (double) HEAD_SLOTS.length); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class Holder implements InventoryHolder { + private final String categoryId; + private final int page; + private Inventory inventory; + + private Holder(@NotNull String categoryId, int page) { + this.categoryId = Objects.requireNonNull(categoryId, "categoryId"); + this.page = page; + } + + private @NotNull String categoryId() { + return categoryId; + } + + private int page() { + return page; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java new file mode 100644 index 0000000..9386dea --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java @@ -0,0 +1,171 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class DeleteCategoryConfirmMenu { + + private static final int SIZE = 27; + private static final String ACTION_CONFIRM = "confirm"; + private static final String ACTION_CANCEL = "cancel"; + + private DeleteCategoryConfirmMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(categoryId, "categoryId"); + + if (!canDelete(plugin, player)) { + noPermission(plugin, player); + return; + } + + Optional category = plugin.customCategories().find(categoryId); + if (category.isEmpty()) { + plugin.guis().openBrowse(player); + return; + } + + Holder holder = new Holder(category.get().id()); + Inventory inventory = Bukkit.createInventory(holder, SIZE, Component.text("Delete Category", NamedTextColor.RED)); + holder.inventory(inventory); + fill(inventory); + inventory.setItem(11, action(plugin, "confirm-yes", ACTION_CONFIRM, category.get())); + inventory.setItem(15, action(plugin, "confirm-no", ACTION_CANCEL, category.get())); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + if (action.get().equals(ACTION_CANCEL)) { + CreateCategoryMenu.openExisting(plugin, player, holder.categoryId()); + return true; + } + + if (!action.get().equals(ACTION_CONFIRM)) { + return true; + } + + if (!canDelete(plugin, player)) { + noPermission(plugin, player); + return true; + } + + Optional category = plugin.customCategories().find(holder.categoryId()); + String label = category.map(value -> categoryLabel(plugin, player, value)).orElse(holder.categoryId()); + boolean deleted = plugin.customCategories().delete(holder.categoryId()); + plugin.clearSearchCache(); + player.sendMessage(Component.text(deleted ? "Category deleted: " : "Category already deleted: ", deleted ? NamedTextColor.GRAY : NamedTextColor.RED).append(Component.text(label, NamedTextColor.GOLD))); + plugin.guis().openBrowse(player); + return true; + } + + private static @NotNull String categoryLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) { + return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN) ? category.name() + " (" + category.id() + ")" : category.name(); + } + + private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull CustomCategory category) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail("Category", category.name()), GuiItems.idDetail("ID", category.id()))); + stamp(plugin, item, action); + return item; + } + + private static boolean canDelete(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + return plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN); + } + + private static void fill(@NotNull Inventory inventory) { + ItemStack item = GuiItems.item(Material.BLACK_STAINED_GLASS_PANE, Component.empty(), List.of()); + for (int slot = 0; slot < inventory.getSize(); slot++) { + inventory.setItem(slot, item.clone()); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "delete_category_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class Holder implements InventoryHolder { + private final String categoryId; + private Inventory inventory; + + private Holder(@NotNull String categoryId) { + this.categoryId = Objects.requireNonNull(categoryId, "categoryId"); + } + + private @NotNull String categoryId() { + return categoryId; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java index e4007dc..f2e95a5 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java @@ -39,7 +39,7 @@ public final class MoreCategoriesMenu { private static final int SLOT_PREVIOUS = 48; private static final int SLOT_ADD = 49; private static final int SLOT_REMOVE = 50; - private static final int SLOT_NEXT = 53; + private static final int SLOT_NEXT = 50; private static final String ACTION_BACK = "back"; private static final String ACTION_PREVIOUS = "previous"; private static final String ACTION_NEXT = "next"; @@ -86,7 +86,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ return; } - List categories = plugin.customCategories().list(); + List categories = plugin.customCategories().listVisible(plugin.adminModes().enabled(player)).stream().filter(category -> Permissions.canViewCategory(player, category.id())).toList(); int pages = pageCount(categories.size()); int page = clampPage(requestedPage, pages); MoreCategoriesHolder holder = new MoreCategoriesHolder(mode, page); @@ -175,6 +175,9 @@ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Invento item.editMeta(meta -> { List lore = new ArrayList<>(); lore.add(GuiItems.idDetail("Heads", category.headIds().size())); + if (category.draft()) { + lore.add(GuiItems.idDetail("State", "DRAFT")); + } lore.add(GuiItems.metaDetail("Icon", iconLabel(category))); lore.add(Component.empty()); @@ -187,7 +190,7 @@ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Invento } } - meta.displayName(GuiItems.name(category.name(), mode == MoreCategoryMode.REMOVE ? NamedTextColor.RED : NamedTextColor.GOLD)); + meta.displayName(GuiItems.name(category.draft() ? "DRAFT - " + category.name() : category.name(), category.draft() ? NamedTextColor.YELLOW : mode == MoreCategoryMode.REMOVE ? NamedTextColor.RED : NamedTextColor.GOLD)); meta.lore(lore); }); return item; @@ -241,7 +244,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p return; } if (action.equals(ACTION_ADD)) { - openEdit(plugin, player, "", "", "CHEST"); + CreateCategoryMenu.open(plugin, player); return; } if (action.equals(ACTION_REMOVE_MODE)) { @@ -265,13 +268,12 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p return; } CustomCategory category = existing.get(); - openEdit(plugin, player, category.id(), category.name(), category.material()); + CreateCategoryMenu.openExisting(plugin, player, category.id()); return; } CustomCategory category = existing.get(); - Set ids = category.headIds().isEmpty() ? Set.of(new HeadId("remote:0")) : category.headIds(); - plugin.guis().openSearch(player, new SearchRequest("", ids, Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false)); + CustomCategoryViewMenu.open(plugin, player, category.id(), 0); return; } if (action.startsWith(ACTION_REMOVE)) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java new file mode 100644 index 0000000..dffbf64 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java @@ -0,0 +1,384 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadTag; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.search.SearchRequest; +import io.github.silentdevelopment.headdb.paper.gui.search.SearchMenuState; +import io.github.silentdevelopment.headdb.query.HeadSort; +import io.github.silentdevelopment.headdb.query.SortDirection; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class TagsMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_BACK = 45; + private static final int SLOT_PREVIOUS = 48; + private static final int SLOT_INFO = 49; + private static final int SLOT_NEXT = 50; + private static final int SLOT_CREATE = 27; + private static final int SLOT_FILTER = 52; + private static final int SLOT_SEARCH = 53; + private static final String ACTION_BACK = "back"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_NEXT = "next"; + private static final String ACTION_CREATE = "create"; + private static final String ACTION_FILTER = "filter"; + private static final String ACTION_SEARCH = "search"; + private static final String ACTION_ENTRY = "tag:"; + private static final int[] ENTRY_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private TagsMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + open(plugin, player, 0, false, ""); + } + + private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage, boolean customOnly, @NotNull String query) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(query, "query"); + + if (!Permissions.has(player, Permissions.GUI_TAGS)) { + noPermission(plugin, player); + return; + } + + boolean adminMode = plugin.adminModes().enabled(player); + Set customIds = customIds(plugin); + Map counts = counts(visibleHeads(plugin, player)); + List entries = plugin.headRegistry().tags().stream() + .filter(entry -> adminMode || counts.containsKey(entry.id())) + .filter(entry -> !customOnly || customIds.contains(entry.id())) + .filter(entry -> matches(entry.id(), entry.name(), query)) + .sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + int pages = pageCount(entries.size()); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + Holder holder = new Holder(page, customOnly, query.trim()); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages, customOnly, query), adminMode)); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderEntries(plugin, player, inventory, entries, counts, customIds, page); + renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + handleAction(plugin, player, holder, action.get()); + return true; + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) { + if (action.equals(ACTION_BACK)) { + plugin.guis().openBrowse(player); + return; + } + + if (action.equals(ACTION_PREVIOUS)) { + open(plugin, player, holder.page() - 1, holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_NEXT)) { + open(plugin, player, holder.page() + 1, holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_CREATE)) { + if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_TAG)) { + noPermission(plugin, player); + return; + } + + CreateTaxonomyMenu.openTag(plugin, player); + return; + } + + if (action.equals(ACTION_FILTER)) { + if (!plugin.adminModes().enabled(player)) { + return; + } + + open(plugin, player, 0, !holder.customOnly(), holder.query()); + return; + } + + if (action.equals(ACTION_SEARCH)) { + player.closeInventory(); + player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter tag search text.", NamedTextColor.GOLD), value -> open(plugin, player, 0, holder.customOnly(), value), () -> open(plugin, player, holder.page(), holder.customOnly(), holder.query())), () -> {}); + return; + } + + if (!action.startsWith(ACTION_ENTRY)) { + return; + } + + String id = action.substring(ACTION_ENTRY.length()); + plugin.guis().openSearch(player, new SearchRequest("", Set.of(), Set.of(), Set.of(id), Set.of(), HeadSort.NAME, SortDirection.ASCENDING, 1, 28, false), SearchMenuState.BackTarget.TAGS); + } + + private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List entries, @NotNull Map counts, @NotNull Set customIds, int page) { + int fromIndex = page * ENTRY_SLOTS.length; + int toIndex = Math.min(entries.size(), fromIndex + ENTRY_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + HeadTag entry = entries.get(index); + ItemStack item = entryItem(plugin, player, entry, counts.getOrDefault(entry.id(), 0), customIds.contains(entry.id())); + stamp(plugin, item, ACTION_ENTRY + entry.id()); + inventory.setItem(ENTRY_SLOTS[slotIndex], item); + slotIndex++; + } + } + + private static @NotNull ItemStack entryItem(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadTag entry, int heads, boolean custom) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("tags")); + item.editMeta(meta -> { + java.util.List lore = new java.util.ArrayList<>(); + if (entry.description() != null && !entry.description().isBlank()) { + lore.add(GuiItems.lore(entry.description(), NamedTextColor.GRAY)); + lore.add(Component.empty()); + } + if (plugin.adminModes().enabled(player)) { + lore.add(GuiItems.idDetail("Heads", heads)); + lore.add(GuiItems.idDetail("ID", entry.id())); + lore.add(GuiItems.idDetail("Type", custom ? "Custom" : "Remote")); + lore.add(Component.empty()); + } + lore.add(GuiItems.lore("Click to browse this tag.", NamedTextColor.GREEN)); + meta.displayName(GuiItems.name(entry.name(), custom ? NamedTextColor.YELLOW : NamedTextColor.GOLD)); + meta.lore(lore); + }); + return item; + } + + private static @NotNull List visibleHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + boolean includeHidden = plugin.adminModes().enabled(player); + return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList(); + } + + private static @NotNull Map counts(@NotNull List heads) { + Map counts = new HashMap<>(); + for (Head head : heads) { + for (String value : head.tags()) { + counts.merge(value, 1, Integer::sum); + } + } + return Map.copyOf(counts); + } + + private static @NotNull Set customIds(@NotNull HeadDBPlugin plugin) { + Set ids = new HashSet<>(); + for (CustomTaxonomyEntry entry : plugin.customTags().list()) { + ids.add(entry.id()); + } + return Set.copyOf(ids); + } + + private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int entries, int page, int pages, boolean customOnly, @NotNull String query) { + ItemStack back = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("back")); + stamp(plugin, back, ACTION_BACK); + inventory.setItem(SLOT_BACK, back); + + if (page > 0) { + ItemStack previous = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("previous")); + stamp(plugin, previous, ACTION_PREVIOUS); + inventory.setItem(SLOT_PREVIOUS, previous); + } + + inventory.setItem(SLOT_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of( + GuiItems.idDetail("Tags", entries), + GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)), + GuiItems.idDetail("Filter", customOnly ? "Custom" : "All"), + GuiItems.idDetail("Search", query.isBlank() ? "None" : query) + ))); + + if (page + 1 < pages) { + ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next")); + stamp(plugin, next, ACTION_NEXT); + inventory.setItem(SLOT_NEXT, next); + } + + if (plugin.adminModes().enabled(player)) { + ItemStack filter = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(customOnly ? "taxonomy-filter-custom" : "taxonomy-filter-all")); + stamp(plugin, filter, ACTION_FILTER); + inventory.setItem(plugin.guiConfig().slot("tags.filter", SLOT_FILTER), filter); + } + + ItemStack search = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("taxonomy-search")); + stamp(plugin, search, ACTION_SEARCH); + inventory.setItem(plugin.guiConfig().slot("tags.search", SLOT_SEARCH), search); + + if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_TAG)) { + ItemStack create = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-tag")); + stamp(plugin, create, ACTION_CREATE); + inventory.setItem(plugin.guiConfig().slot("tags.create", SLOT_CREATE), create); + } + } + + private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) { + String trimmed = query.trim().toLowerCase(Locale.ROOT); + if (trimmed.isBlank()) { + return true; + } + + String haystack = id.toLowerCase(Locale.ROOT) + " " + name.toLowerCase(Locale.ROOT); + for (String part : trimmed.split("\\s+")) { + if (part.isBlank()) { + continue; + } + if (!haystack.contains(part)) { + return false; + } + } + return true; + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + inventory.setItem(slot, filler); + } + } + + private static @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages, boolean customOnly, @NotNull String query) { + String key = customOnly ? "title.tags-custom-page" : "title.tags-page"; + String fallback = customOnly ? "Custom Tags %page%/%pages%" : "Tags %page%/%pages%"; + return plugin.guiConfig().text(key, fallback) + .replace("%page%", String.valueOf(page + 1)) + .replace("%pages%", String.valueOf(Math.max(1, pages))) + .replace("%query%", query.trim()); + } + + private static int pageCount(int entries) { + if (entries <= 0) { + return 1; + } + return (int) Math.ceil((double) entries / (double) ENTRY_SLOTS.length); + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "tags_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class Holder implements InventoryHolder { + private final int page; + private final boolean customOnly; + private final String query; + private Inventory inventory; + + private Holder(int page, boolean customOnly, @NotNull String query) { + this.page = page; + this.customOnly = customOnly; + this.query = Objects.requireNonNull(query, "query"); + } + + private int page() { return page; } + private boolean customOnly() { return customOnly; } + private @NotNull String query() { return query; } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java index 15a4b16..2197840 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java @@ -144,7 +144,7 @@ public static void fillEmpty(@NotNull HeadDBPlugin plugin, @NotNull PaperPag public static @NotNull Component metaDetail(@NotNull String key, @NotNull Object value) { Objects.requireNonNull(key, "key"); Objects.requireNonNull(value, "value"); - return detail(key, value, NamedTextColor.DARK_GRAY); + return detail(key, value, NamedTextColor.GOLD); } public static @NotNull Component mini(@NotNull String value) { @@ -160,7 +160,7 @@ public static void fillEmpty(@NotNull HeadDBPlugin plugin, @NotNull PaperPag return mini(value); } - return Component.text(value, NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false); + return Component.text(value, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false); } public static @NotNull List miniLore(@NotNull List lines) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java new file mode 100644 index 0000000..ac10fe4 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java @@ -0,0 +1,80 @@ +package io.github.silentdevelopment.headdb.paper.gui.common; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.model.HeadCategory; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategory; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public final class GuiLabels { + + private GuiLabels() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static @NotNull String head(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(id, "id"); + + return plugin.headRegistry().find(id).map(head -> head(plugin, player, head)).orElse(id.display()); + } + + public static @NotNull String head(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Head head) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(head, "head"); + + if (adminLabel(plugin, player)) { + return head.name() + " (" + head.id().display() + ")"; + } + + return head.name(); + } + + public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String id) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(id, "id"); + + return plugin.customCategories().find(id) + .map(category -> category(plugin, player, category)) + .or(() -> plugin.headRegistry().category(id).map(category -> category(plugin, player, category))) + .orElse(id); + } + + public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadCategory category) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(category, "category"); + + if (adminLabel(plugin, player)) { + return category.name() + " (" + category.id() + ")"; + } + + return category.name(); + } + + public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(category, "category"); + + if (adminLabel(plugin, player)) { + return category.name() + " (" + category.id() + ")"; + } + + return category.name(); + } + + public static boolean adminLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_EDIT) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java index d1a3e1b..b8ff317 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java @@ -9,8 +9,6 @@ public final class GuiTitles { - private static final int ADMIN_TITLE_PIXEL_TARGET = 134; - private static final int SPACE_WIDTH = 4; private GuiTitles() { throw new UnsupportedOperationException("This class cannot be instantiated."); @@ -19,16 +17,7 @@ private GuiTitles() { public static @NotNull Component title(@NotNull String title, boolean adminMode) { Objects.requireNonNull(title, "title"); - String clean = title.trim(); - if (!adminMode) { - return Component.text(clean, NamedTextColor.RED).decoration(TextDecoration.ITALIC, false); - } - - int spaces = Math.max(2, (int) Math.ceil((double) (ADMIN_TITLE_PIXEL_TARGET - pixelWidth(clean) - pixelWidth("ADMIN")) / (double) SPACE_WIDTH)); - return Component.text(clean, NamedTextColor.RED) - .append(Component.text(" ".repeat(spaces))) - .append(Component.text("ADMIN", NamedTextColor.DARK_RED, TextDecoration.BOLD)) - .decoration(TextDecoration.ITALIC, false); + return Component.text(title.trim(), NamedTextColor.RED).decoration(TextDecoration.ITALIC, false); } public static @NotNull Component title(@NotNull String prefix, @NotNull String detail, boolean adminMode) { @@ -39,24 +28,6 @@ private GuiTitles() { return title(clean, adminMode); } - private static int pixelWidth(@NotNull String value) { - int width = 0; - for (int index = 0; index < value.length(); index++) { - width += characterWidth(value.charAt(index)); - } - return width; - } - - private static int characterWidth(char character) { - return switch (character) { - case ' ', '!', ',', '.', ':', ';', '|', 'i', 'l', '\'' -> 2; - case '[', ']', 'I', 't' -> 4; - case '"', '(', ')', '*', '<', '>', 'f', 'k' -> 5; - case '@', '~' -> 7; - default -> 6; - }; - } - private static @NotNull String shortTitle(@NotNull String value) { if (value.length() <= 16) { return value; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java index a43643a..024664b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java @@ -25,18 +25,47 @@ public final class GuiConfig { "refresh", "reload", "close", + "browse", "browse-all", "search", "player-heads", "custom-heads", "hidden-heads", "favorites", + "collections", + "tags", + "create-tag", + "create-collection", + "create-category", + "taxonomy-id", + "taxonomy-name", + "taxonomy-description", + "taxonomy-save", + "taxonomy-filter-all", + "taxonomy-filter-custom", + "taxonomy-search", + "create-head", + "create-head-held", + "create-head-fresh", + "create-head-id", + "create-head-name", + "create-head-texture", + "create-head-category", + "create-head-tags", + "create-head-collections", + "create-head-save", "more-categories", "more-category", "category", "category-add", "category-remove", "category-name", + "category-description", + "category-permission", + "category-save-draft", + "category-publish", + "category-delete", + "category-price", "category-id", "category-material", "category-head-icon", @@ -59,6 +88,8 @@ public final class GuiConfig { "edit-visibility-visible", "edit-visibility-hidden", "edit-delete", + "edit-publish", + "edit-price", "edit-reset", "lore-add", "lore-clear", @@ -79,6 +110,7 @@ public final class GuiConfig { "filter-category", "filter-tags", "filter-collections", + "filter-ids", "filter-selected", "filter-unselected", "clear-filters", diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java new file mode 100644 index 0000000..9c6cadf --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java @@ -0,0 +1,198 @@ +package io.github.silentdevelopment.headdb.paper.gui.edit; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.Optional; + +public final class DeleteHeadConfirmMenu { + + private static final int SIZE = 27; + private static final int SLOT_PREVIEW = 4; + private static final int SLOT_CONFIRM = 11; + private static final int SLOT_CANCEL = 15; + private static final String ACTION_CONFIRM = "confirm"; + private static final String ACTION_CANCEL = "cancel"; + + private DeleteHeadConfirmMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(id, "id"); + + if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_DELETE)) { + noPermission(plugin, player); + return; + } + + Optional head = plugin.headRegistry().find(id); + if (head.isEmpty()) { + player.sendMessage(Component.text("Custom head no longer exists.", NamedTextColor.RED)); + return; + } + + ConfirmHolder holder = new ConfirmHolder(id); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title("Delete Head", true)); + holder.inventory(inventory); + fill(plugin, inventory); + + ItemStack preview = plugin.itemFactory().create(head.get()); + preview.editMeta(meta -> { + java.util.List lore = meta.lore() == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(meta.lore()); + lore.add(Component.empty()); + lore.add(GuiItems.idDetail("ID", id.display())); + lore.add(GuiItems.metaDetail("Name", head.get().name())); + lore.add(GuiItems.lore("This action cannot be undone.", NamedTextColor.RED)); + meta.lore(lore); + }); + inventory.setItem(SLOT_PREVIEW, preview); + ItemStack confirm = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("confirm-yes")); + stamp(plugin, confirm, ACTION_CONFIRM); + inventory.setItem(SLOT_CONFIRM, confirm); + + ItemStack cancel = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("confirm-no")); + stamp(plugin, cancel, ACTION_CANCEL); + inventory.setItem(SLOT_CANCEL, cancel); + + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof ConfirmHolder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + if (action.get().equals(ACTION_CANCEL)) { + HeadEditMenu.open(plugin, player, holder.id()); + return true; + } + + if (!action.get().equals(ACTION_CONFIRM)) { + return true; + } + + if (!Permissions.has(player, Permissions.CUSTOM_DELETE)) { + noPermission(plugin, player); + return true; + } + + String label = GuiLabels.head(plugin, player, holder.id()); + if (!plugin.headRegistry().customHeads().delete(holder.id())) { + player.closeInventory(); + player.sendMessage(Component.text("Custom head no longer exists: ", NamedTextColor.RED).append(Component.text(holder.id().display(), NamedTextColor.GOLD))); + return true; + } + + plugin.headRegistry().onLocalMutation(); + plugin.clearItemCache(); + plugin.clearSearchCache(); + player.closeInventory(); + player.sendMessage(Component.text("Custom head deleted: ", NamedTextColor.GRAY).append(Component.text(label, NamedTextColor.GOLD))); + return true; + } + + private static void fill(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = new ItemStack(material); + filler.editMeta(meta -> { + meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name())); + meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore())); + }); + + for (int slot = 0; slot < SIZE; slot++) { + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "delete_head_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + } + + private static final class ConfirmHolder implements InventoryHolder { + private final HeadId id; + private Inventory inventory; + + private ConfirmHolder(@NotNull HeadId id) { + this.id = id; + } + + private @NotNull HeadId id() { + return id; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java index 4b97897..f958cc8 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java @@ -1,13 +1,23 @@ package io.github.silentdevelopment.headdb.paper.gui.edit; import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels; import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.model.HeadId; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu; import io.github.silentdevelopment.headdb.paper.gui.category.MoreCategoriesMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.BrowseMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CollectionsMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.TagsMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CreateHeadMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CreateCategoryMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CreateTaxonomyMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.DeleteCategoryConfirmMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategoryViewMenu; import io.github.silentdevelopment.headdb.paper.gui.category.CategoryMembersMenu; import io.github.silentdevelopment.headdb.paper.gui.category.CategoryHeadPickerMenu; +import io.github.silentdevelopment.headdb.paper.gui.settings.CommandConfirmMenu; import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu; import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoriteClickGuard; import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu; @@ -27,6 +37,8 @@ import org.bukkit.event.Listener; import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; @@ -38,6 +50,8 @@ import java.util.Optional; import java.util.Set; +import static io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu.give; + public final class HeadEditListener implements Listener { private final HeadDBPlugin plugin; @@ -46,6 +60,22 @@ public HeadEditListener(@NotNull HeadDBPlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClose(@NotNull InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player player)) { + return; + } + + InventoryHolder holder = event.getInventory().getHolder(); + if (holder == null) { + return; + } + + CreateHeadMenu.handleClose(plugin, player, holder); + CreateCategoryMenu.handleClose(plugin, player, holder); + } + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false) public void onInventoryClick(@NotNull InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) { @@ -78,6 +108,50 @@ public void onInventoryClick(@NotNull InventoryClickEvent event) { } } + if (BrowseMenu.handleClick(plugin, player, event)) { + return; + } + + if (CollectionsMenu.handleClick(plugin, player, event)) { + return; + } + + if (TagsMenu.handleClick(plugin, player, event)) { + return; + } + + if (CommandConfirmMenu.handleClick(plugin, player, event)) { + return; + } + + if (CreateHeadMenu.handleClick(plugin, player, event)) { + return; + } + + if (CreateCategoryMenu.handleClick(plugin, player, event)) { + return; + } + + if (CreateTaxonomyMenu.handleClick(plugin, player, event)) { + return; + } + + if (CustomCategoryViewMenu.handleClick(plugin, player, event, id -> openEdit(player, id), item -> giveListedHead(player, item))) { + return; + } + + if (DeleteCategoryConfirmMenu.handleClick(plugin, player, event)) { + return; + } + + if (DeleteHeadConfirmMenu.handleClick(plugin, player, event)) { + return; + } + + if (handleAdvancedSearchClick(player, event)) { + return; + } + if (HiddenHeadsMenu.handleClick(plugin, player, event)) { return; } @@ -147,6 +221,30 @@ public void onInventoryClick(@NotNull InventoryClickEvent event) { } } + private boolean handleAdvancedSearchClick(@NotNull Player player, @NotNull InventoryClickEvent event) { + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return false; + } + + if (event.getClick() != ClickType.RIGHT && event.getClick() != ClickType.SHIFT_RIGHT) { + return false; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return false; + } + + Optional iconKey = GuiButtonEditorMenu.readIconKey(plugin, item); + if (iconKey.isEmpty() || !iconKey.get().equals("search")) { + return false; + } + + deny(event); + plugin.guis().openAdvancedSearch(player); + return true; + } + private void toggleFavorite(@NotNull Player player, @NotNull HeadId id) { if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) { @@ -155,7 +253,7 @@ private void toggleFavorite(@NotNull Player player, @NotNull HeadId id) { } boolean added = plugin.favorites().toggle(player.getUniqueId(), id); - player.sendMessage(Component.text(added ? "Added favorite: " : "Removed favorite: ", added ? NamedTextColor.YELLOW : NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD))); + player.sendMessage(Component.text(added ? "Added favorite: " : "Removed favorite: ", added ? NamedTextColor.YELLOW : NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); } private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) { @@ -283,7 +381,17 @@ private void handleAction(@NotNull Player player, @NotNull InventoryClickEvent e } if (action.equals(HeadEditMenu.ACTION_DELETE)) { - deleteCustom(player, id); + confirmDeleteCustom(player, id); + return; + } + + if (action.equals(HeadEditMenu.ACTION_PUBLISH)) { + publishCustom(player, id); + return; + } + + if (action.equals(HeadEditMenu.ACTION_PRICE)) { + promptPrice(player, id); return; } @@ -374,11 +482,11 @@ private void setCategory(@NotNull Player player, @NotNull HeadId id, @NotNull St plugin.headRegistry().overrides().save(override.withCategory(category, player.getUniqueId())); } else if (id.isCustom()) { StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); - plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), stored.tags(), stored.collections(), category, stored.createdAt(), Instant.now(), stored.createdBy())); + plugin.headRegistry().customHeads().save(stored.withCategory(category)); } mutated(player, id); - player.sendMessage(Component.text("Category set to ", NamedTextColor.GRAY).append(Component.text(category, NamedTextColor.GOLD))); + player.sendMessage(Component.text("Category set to ", NamedTextColor.GRAY).append(Component.text(GuiLabels.category(plugin, player, category), NamedTextColor.GOLD))); HeadEditMenu.open(plugin, player, id); } @@ -394,7 +502,7 @@ private void toggleTag(@NotNull Player player, @NotNull HeadId id, @NotNull Stri } else if (id.isCustom()) { StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); Set tags = toggle(stored.tags(), tag); - plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), tags, stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy())); + plugin.headRegistry().customHeads().save(stored.withTags(tags)); } mutated(player, id); @@ -413,7 +521,7 @@ private void toggleCollection(@NotNull Player player, @NotNull HeadId id, @NotNu } else if (id.isCustom()) { StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); Set collections = toggle(stored.collections(), collection); - plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), stored.tags(), collections, stored.category(), stored.createdAt(), Instant.now(), stored.createdBy())); + plugin.headRegistry().customHeads().save(stored.withCollections(collections)); } mutated(player, id); @@ -446,16 +554,53 @@ private void reset(@NotNull Player player, @NotNull HeadId id) { HeadEditMenu.open(plugin, player, id); } - private void deleteCustom(@NotNull Player player, @NotNull HeadId id) { + private void confirmDeleteCustom(@NotNull Player player, @NotNull HeadId id) { if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_DELETE)) { noPermission(player); return; } - plugin.headRegistry().customHeads().delete(id); + DeleteHeadConfirmMenu.open(plugin, player, id); + } + + + private void publishCustom(@NotNull Player player, @NotNull HeadId id) { + if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_CREATE)) { + noPermission(player); + return; + } + + StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); + plugin.headRegistry().customHeads().save(stored.withDraft(false)); mutated(player, id); + player.sendMessage(Component.text("Draft published: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); + HeadEditMenu.open(plugin, player, id); + } + + private void promptPrice(@NotNull Player player, @NotNull HeadId id) { + if (!plugin.economy().enabled()) { + HeadEditMenu.open(plugin, player, id); + return; + } + + if (!editable(player, id, Permissions.EDIT)) { + return; + } + player.closeInventory(); - player.sendMessage(Component.text("Custom head deleted: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD))); + plugin.prompts().request(player, Component.text("Enter the new head price, or 0 to clear.", NamedTextColor.GOLD), value -> { + try { + double price = Double.parseDouble(value.trim()); + plugin.economy().setHeadPrice(id, price); + player.sendMessage(plugin.messages().priceUpdated(player, GuiLabels.head(plugin, player, id), plugin.economy().format(price))); + } catch (NumberFormatException exception) { + player.sendMessage(plugin.messages().priceInvalid(player)); + } catch (RuntimeException exception) { + player.sendMessage(Component.text("Failed to update head price: " + exception.getMessage(), NamedTextColor.RED)); + } + + HeadEditMenu.open(plugin, player, id); + }, () -> HeadEditMenu.open(plugin, player, id)); } private void promptLoreAdd(@NotNull Player player, @NotNull HeadId id) { @@ -515,7 +660,7 @@ private void setLore(@NotNull Player player, @NotNull HeadId id, @NotNull List new IllegalArgumentException("Unknown custom head: " + id)); - plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), cleaned, stored.tags(), stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy())); + plugin.headRegistry().customHeads().save(stored.withLore(cleaned)); } mutated(player, id); @@ -534,7 +679,7 @@ private void resetLore(@NotNull Player player, @NotNull HeadId id) { plugin.headRegistry().overrides().save(override.withLore(null, player.getUniqueId())); } else if (id.isCustom()) { StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); - plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), List.of(), stored.tags(), stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy())); + plugin.headRegistry().customHeads().save(stored.withLore(List.of())); } mutated(player, id); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java index dc80963..63d79a4 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java @@ -10,7 +10,10 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategory; import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; +import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; @@ -28,7 +31,9 @@ import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -46,6 +51,8 @@ public final class HeadEditMenu { public static final String ACTION_VISIBILITY = "visibility"; public static final String ACTION_RESET = "reset"; public static final String ACTION_DELETE = "delete"; + public static final String ACTION_PUBLISH = "publish"; + public static final String ACTION_PRICE = "price"; public static final String ACTION_BACK_MAIN = "back-main"; public static final String ACTION_BACK_EDIT = "back-edit"; public static final String ACTION_PREVIOUS = "previous"; @@ -114,11 +121,18 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player inventory.setItem(plugin.guiConfig().slot("edit.category", 29), button(plugin, id, ACTION_CATEGORY_MENU, "edit-category")); inventory.setItem(plugin.guiConfig().slot("edit.tags", 31), button(plugin, id, ACTION_TAGS_MENU, "edit-tags")); inventory.setItem(plugin.guiConfig().slot("edit.collections", 33), button(plugin, id, ACTION_COLLECTIONS_MENU, "edit-collections")); + if (plugin.economy().enabled()) { + inventory.setItem(plugin.guiConfig().slot("edit.price", 35), button(plugin, id, ACTION_PRICE, "edit-price")); + } if (id.isRemote()) { inventory.setItem(plugin.guiConfig().slot("edit.visibility", 39), visibilityButton(plugin, id)); inventory.setItem(plugin.guiConfig().slot("edit.reset", 41), button(plugin, id, ACTION_RESET, "edit-reset")); } else if (id.isCustom()) { + boolean draft = plugin.headRegistry().customHeads().findStored(id).map(StoredCustomHead::draft).orElse(false); + if (draft) { + inventory.setItem(plugin.guiConfig().slot("edit.publish", 39), button(plugin, id, ACTION_PUBLISH, "edit-publish")); + } inventory.setItem(plugin.guiConfig().slot("edit.delete", 41), button(plugin, id, ACTION_DELETE, "edit-delete")); } @@ -126,9 +140,18 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player } public static void openCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id, int page) { - List entries = plugin.headRegistry().categories().stream().sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)).map(category -> new Entry(category.id(), category.name())).toList(); + Map entries = new LinkedHashMap<>(); + plugin.headRegistry().categories().stream() + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)) + .forEach(category -> entries.put(category.id(), new Entry(category.id(), category.name()))); + boolean adminMode = plugin.adminModes().enabled(player); + plugin.customCategories().listVisible(adminMode).stream() + .filter(category -> Permissions.canViewCategory(player, category.id())) + .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER)) + .forEach(category -> entries.put(category.id(), new Entry(category.id(), category.draft() ? "DRAFT - " + category.name() : category.name()))); String current = previewHead(plugin, id).map(Head::category).orElse("unknown"); - openSelection(plugin, player, id, EditMenuType.CATEGORY, page, entries, "Category: " + current, ACTION_CATEGORY_SELECT, selectedCategory(plugin, id)); + openSelection(plugin, player, id, EditMenuType.CATEGORY, page, List.copyOf(entries.values()), "Category: " + current, ACTION_CATEGORY_SELECT, selectedCategory(plugin, id)); } public static void openTags(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id, int page) { @@ -226,6 +249,10 @@ private static void openSelection(@NotNull HeadDBPlugin plugin, @NotNull Player return Optional.of(playerHead(id)); } + if (id.isCustom()) { + return plugin.headRegistry().customHeads().findStored(id).map(StoredCustomHead::toHead); + } + return plugin.headRegistry().find(id); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java index 9bd7d23..c707503 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java @@ -187,7 +187,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p } } - private static void give(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) { + public static void give(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) { if (!Permissions.has(player, Permissions.GUI_HEAD_TAKE)) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); return; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java index c55bf30..2516627 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java @@ -6,6 +6,7 @@ import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverride; @@ -70,7 +71,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, in int pages = pageCount(heads.size()); int page = clampPage(requestedPage, pages); HiddenHeadsHolder holder = new HiddenHeadsHolder(page); - Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title("Hidden Heads " + (page + 1) + "/" + pages, true)); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages), true)); holder.inventory(inventory); fillBorder(inventory); @@ -130,7 +131,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player plugin.headRegistry().onLocalMutation(); plugin.clearItemCache(); plugin.clearSearchCache(); - player.sendMessage(Component.text("Head shown: ", NamedTextColor.GRAY).append(Component.text(id.get().display(), NamedTextColor.GOLD))); + player.sendMessage(Component.text("Head shown: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id.get()), NamedTextColor.GOLD))); open(plugin, player, holder.page()); return true; } @@ -173,7 +174,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HiddenHeadsHolder holder, @NotNull String action) { if (action.equals(ACTION_BACK)) { - plugin.guis().openMain(player); + plugin.guis().openBrowse(player); return; } @@ -242,6 +243,10 @@ private static void fillBorder(@NotNull Inventory inventory) { return id.display(); } + private static @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages) { + return plugin.guiConfig().text("title.hidden-heads-page", "Hidden Heads %page%/%pages%").replace("%page%", String.valueOf(page + 1)).replace("%pages%", String.valueOf(Math.max(1, pages))); + } + private static int pageCount(int entries) { if (entries <= 0) { return 1; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java index 36f8f87..604a76a 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java @@ -10,6 +10,7 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; import io.github.silentdevelopment.headdb.paper.local.player.PlayerHeadEntry; +import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead; import io.github.silentdevelopment.headdb.paper.permission.Permissions; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -43,7 +44,7 @@ public final class LocalHeadListMenu { private static final int SLOT_BACK = 45; private static final int SLOT_PREVIOUS = 48; private static final int SLOT_INFO = 49; - private static final int SLOT_NEXT = 53; + private static final int SLOT_NEXT = 50; private static final int[] HEAD_SLOTS = { 10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, @@ -67,7 +68,7 @@ public static void openCustom(@NotNull HeadDBPlugin plugin, @NotNull Player play Objects.requireNonNull(plugin, "plugin"); Objects.requireNonNull(player, "player"); - List heads = plugin.headRegistry().customHeads().list().stream().sorted(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList(); + List heads = (plugin.adminModes().enabled(player) ? plugin.headRegistry().customHeads().listStored().stream().map(StoredCustomHead::toHead).toList() : plugin.headRegistry().customHeads().list()).stream().sorted(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList(); open(plugin, player, LocalHeadListType.CUSTOM, heads, page); } @@ -146,7 +147,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ int page = clampPage(requestedPage, pages); LocalHeadListHolder holder = new LocalHeadListHolder(type, page); boolean adminMode = plugin.adminModes().enabled(player); - Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(type, page, pages), adminMode)); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, type, page, pages), adminMode)); holder.inventory(inventory); fillBorder(inventory); @@ -195,6 +196,11 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull LocalHeadListHolder holder, @NotNull String action) { if (action.equals(ACTION_BACK)) { + if (holder.type() == LocalHeadListType.CUSTOM) { + plugin.guis().openBrowse(player); + return; + } + plugin.guis().openMain(player); return; } @@ -283,8 +289,10 @@ private static void fillBorder(@NotNull Inventory inventory) { return new org.bukkit.NamespacedKey(plugin, "local_menu_action"); } - private static @NotNull String title(@NotNull LocalHeadListType type, int page, int pages) { - return type.displayName() + " " + (page + 1) + "/" + pages; + private static @NotNull String title(@NotNull HeadDBPlugin plugin, @NotNull LocalHeadListType type, int page, int pages) { + String key = type == LocalHeadListType.CUSTOM ? "title.custom-heads-page" : "title.player-heads-page"; + String fallback = type.displayName() + " %page%/%pages%"; + return plugin.guiConfig().text(key, fallback).replace("%page%", String.valueOf(page + 1)).replace("%pages%", String.valueOf(Math.max(1, pages))); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java index ac1b0b7..69cbc1f 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java @@ -9,17 +9,11 @@ import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory; import io.github.silentdevelopment.headdb.database.DatabaseStats; import io.github.silentdevelopment.headdb.database.DatabaseStatus; -import io.github.silentdevelopment.headdb.model.Head; -import io.github.silentdevelopment.headdb.model.HeadCategory; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.MenuState; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; -import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu; -import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu; -import io.github.silentdevelopment.headdb.paper.gui.config.GuiIconConfig; -import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; import io.github.silentdevelopment.headdb.paper.gui.settings.SettingsPageFactory; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; @@ -30,18 +24,13 @@ import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; -import org.bukkit.Material; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.MenuType; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Set; @@ -50,16 +39,8 @@ public final class MainPageFactory implements PaperPageFactory { public static final GKey KEY = GKey.of("main"); - private static final String CATEGORY_PAGE_STATE_KEY = "headdb.main.category-page"; private static final int ROWS = 6; private static final int SLOT_CLOSE = 45; - private static final int SLOT_PREVIOUS = 48; - private static final int SLOT_NEXT = 50; - private static final int[] CATEGORY_SLOTS = { - 10, 11, 12, 13, 14, 15, 16, - 19, 20, 21, 22, 23, 24, 25, - 37, 38, 39, 40, 41, 42, 43 - }; private final HeadDBPlugin plugin; @@ -79,147 +60,54 @@ public MainPageFactory(@NotNull HeadDBPlugin plugin) { Player player = player(context); boolean adminMode = player != null && plugin.adminModes().enabled(player); + Set reservedSlots = new HashSet<>(); page.type(MenuType.GENERIC_9X6); - page.title(GuiTitles.title("HeadDB", adminMode)); - - Set reservedSlots = new HashSet<>(); - renderCategories(context, page, reservedSlots, adminMode); + page.title(GuiTitles.title(plugin.guiConfig().text("title.main", "HeadDB"), adminMode)); renderControls(context, page, reservedSlots, adminMode); GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots); - return page.build(); } - private void renderCategories(@NotNull GuiContext context, @NotNull PaperPageBuilder page, @NotNull Set reservedSlots, boolean adminMode) { - Player player = player(context); - if (player == null) { - return; - } - - if (!Permissions.has(player, Permissions.GUI_CATEGORY_VIEW)) { - return; - } - - List categories = categories(player); - Map iconHeads = categoryIconHeads(categories, adminMode); - Map counts = plugin.headRegistry().categoryCounts(adminMode); - int pageIndex = categoryPage(context); - int fromIndex = pageIndex * CATEGORY_SLOTS.length; - - if (fromIndex >= categories.size() && pageIndex > 0) { - pageIndex = Math.max(0, pageCount(categories.size()) - 1); - setCategoryPage(context, pageIndex); - fromIndex = pageIndex * CATEGORY_SLOTS.length; - } - - int toIndex = Math.min(categories.size(), fromIndex + CATEGORY_SLOTS.length); - int slotIndex = 0; - for (int index = fromIndex; index < toIndex; index++) { - HeadCategory category = categories.get(index); - int slot = CATEGORY_SLOTS[slotIndex]; - set(page, reservedSlots, slot, categoryButton(category, iconHeads.get(category.id()), counts.getOrDefault(category.id(), 0), adminMode)); - slotIndex++; - } - } - private void renderControls(@NotNull GuiContext context, @NotNull PaperPageBuilder page, @NotNull Set reservedSlots, boolean adminMode) { Player player = player(context); if (player == null) { return; } - List categories = categories(player); - int categoryPage = categoryPage(context); - int categoryPages = pageCount(categories.size()); - set(page, reservedSlots, SLOT_CLOSE, closeButton()); - if (Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player)) { - set(page, reservedSlots, slot("main.browse-heads", 31), browseAllButton()); - } - if (Permissions.has(player, Permissions.GUI_PLAYER_HEADS)) { - set(page, reservedSlots, slot("main.player-heads", 37), playerHeadsButton()); + set(page, reservedSlots, slot("main.player-heads", 11), playerHeadsButton()); } - if (Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) { - set(page, reservedSlots, slot("main.custom-heads", 28), customHeadsButton()); + if (Permissions.has(player, Permissions.GUI_BROWSE_MENU)) { + set(page, reservedSlots, slot("main.browse", 13), browseButton()); } if (Permissions.has(player, Permissions.GUI_FAVORITES)) { - set(page, reservedSlots, slot("main.favorites", 40), favoritesButton()); - } - - if (adminMode && Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) { - set(page, reservedSlots, slot("main.hidden-heads", 51), hiddenHeadsButton()); + set(page, reservedSlots, slot("main.favorites", 15), favoritesButton()); } if (Permissions.has(player, Permissions.GUI_SEARCH)) { - set(page, reservedSlots, slot("main.search", 43), searchButton()); - } - - if (Permissions.has(player, Permissions.GUI_MORE_CATEGORIES)) { - set(page, reservedSlots, slot("main.more-categories", 34), moreCategoriesButton()); - } - - if (categoryPage > 0) { - set(page, reservedSlots, SLOT_PREVIOUS, previousCategoriesButton(categoryPage)); + set(page, reservedSlots, slot("main.search", 31), searchButton()); } - set(page, reservedSlots, slot("main.info", 49), infoButton(categories.size(), adminMode)); - - if (categoryPage + 1 < categoryPages) { - set(page, reservedSlots, SLOT_NEXT, nextCategoriesButton(categoryPage)); - } + set(page, reservedSlots, slot("main.info", 49), infoButton(visibleCategories(player), adminMode)); if (Permissions.has(player, Permissions.GUI_SETTINGS)) { set(page, reservedSlots, slot("main.settings", 53), settingsButton()); } } - private @NotNull ItemElement categoryButton(@NotNull HeadCategory category, Head iconHead, int amount, boolean adminMode) { - Objects.requireNonNull(category, "category"); - - return ItemElement.of(GKey.of("category_" + category.id()), context -> { - Player player = player(context); - boolean showDetails = player != null && adminMode; - return categoryItem(category, iconHead, amount, showDetails); - }, context -> { - Player player = player(context); - if (player == null) { - return; - } - - if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, category.id())) { - player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); - return; - } - - plugin.guis().openSearch(player, categoryRequest(category)); - }); - } - private @NotNull ItemElement closeButton() { return GuiHeadIcons.button(plugin, "close", "close", context -> { Player player = player(context); - if (player != null) { - player.closeInventory(); + if (player == null) { + return; } - }); - } - - private @NotNull ItemElement previousCategoriesButton(int currentPage) { - return GuiHeadIcons.button(plugin, "previous_categories", "previous", context -> { - setCategoryPage(context, currentPage - 1); - context.refresh(); - }); - } - private @NotNull ItemElement nextCategoriesButton(int currentPage) { - return GuiHeadIcons.button(plugin, "next_categories", "next", context -> { - setCategoryPage(context, currentPage + 1); - context.refresh(); + player.closeInventory(); }); } @@ -240,7 +128,7 @@ private void renderControls(@NotNull GuiContext context, @NotNull Pap lore.add(GuiItems.idDetail("Hidden heads", plugin.headRegistry().hiddenHeads().size())); } - return GuiHeadIcons.button(plugin, "main_info", "info", GuiItems.name("HeadDB", adminMode ? NamedTextColor.RED : NamedTextColor.GOLD), lore, ignored -> {}); + return GuiHeadIcons.button(plugin, "main_info", "info", GuiItems.mini(plugin.guiConfig().icon("info").name()), lore, ignored -> {}); } private @NotNull ItemElement searchButton() { @@ -266,70 +154,39 @@ private void renderControls(@NotNull GuiContext context, @NotNull Pap private @NotNull ItemElement playerHeadsButton() { return GuiHeadIcons.button(plugin, "player_heads", plugin.guiConfig().icon("player-heads"), context -> { Player player = player(context); - if (player != null) { - plugin.guis().openPlayerHeads(player); + if (player == null) { + return; } - }, context -> mainButtonName("player-heads"), context -> mainButtonLore(context, "player-heads", plugin.headRegistry().playerHeads().knownPlayers().size())); - } - private @NotNull ItemElement customHeadsButton() { - return GuiHeadIcons.button(plugin, "custom_heads", plugin.guiConfig().icon("custom-heads"), context -> { - Player player = player(context); - if (player != null) { - plugin.guis().openCustomHeads(player); - } - }, context -> mainButtonName("custom-heads"), context -> mainButtonLore(context, "custom-heads", plugin.headRegistry().customHeads().list().size())); + plugin.guis().openPlayerHeads(player); + }, context -> mainButtonName("player-heads"), context -> mainButtonLore(context, "player-heads", plugin.headRegistry().playerHeads().knownPlayers().size())); } - private @NotNull ItemElement favoritesButton() { return GuiHeadIcons.button(plugin, "favorites", "favorites", context -> { - Player player = player(context); - if (player != null) { - plugin.guis().openFavorites(player); - } - }); - } - - private @NotNull ItemElement moreCategoriesButton() { - return GuiHeadIcons.button(plugin, "more_categories", "more-categories", context -> { - Player player = player(context); - if (player != null) { - plugin.guis().openMoreCategories(player); - } - }); - } - - private @NotNull ItemElement hiddenHeadsButton() { - return GuiHeadIcons.button(plugin, "hidden_heads", "hidden-heads", context -> { Player player = player(context); if (player == null) { return; } - if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) { - player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); - return; - } - - HiddenHeadsMenu.open(plugin, player); + plugin.guis().openFavorites(player); }); } - private @NotNull ItemElement browseAllButton() { - return GuiHeadIcons.button(plugin, "browse_all", plugin.guiConfig().icon("browse-all"), context -> { + private @NotNull ItemElement browseButton() { + return GuiHeadIcons.button(plugin, "browse", plugin.guiConfig().icon("browse"), context -> { Player player = player(context); if (player == null) { return; } - if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) { + if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); return; } - plugin.guis().openSearch(player, browseRequest()); - }, context -> mainButtonName("browse-all"), context -> mainButtonLore(context, "browse-all", plugin.headRegistry().heads(adminMode(context)).size())); + plugin.guis().openBrowse(player); + }, context -> mainButtonName("browse"), context -> mainButtonLore(context, "browse", visibleCategories(context))); } private @NotNull ItemElement settingsButton() { @@ -366,162 +223,24 @@ private boolean adminMode(@NotNull GuiContext context) { return player != null && plugin.adminModes().enabled(player); } - private @NotNull ItemStack categoryItem(@NotNull HeadCategory category, Head iconHead, int amount, boolean showTechnicalDetails) { - Objects.requireNonNull(category, "category"); - - String iconKey = categoryIconKey(category.id()); - boolean configured = plugin.guiConfig().hasIcon(iconKey); - GuiIconConfig icon = plugin.guiConfig().iconOrDefault(iconKey, "category"); - ItemStack item = categoryIcon(category, iconHead, configured, icon); - - item.editMeta(meta -> { - List lore = new ArrayList<>(); - - if (configured) { - lore.addAll(GuiItems.miniLore(replaceAll(icon.lore(), category, amount))); - } else { - String description = category.description(); - if (description != null && !description.isBlank()) { - lore.add(Component.text(description, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); - } - } - - if (!lore.isEmpty()) { - lore.add(Component.empty()); - } - - if (showTechnicalDetails) { - lore.add(GuiItems.idDetail("Heads", amount)); - lore.add(GuiItems.idDetail("ID", category.id())); - lore.add(Component.empty()); - } - - lore.add(Component.text("Click to browse this category.", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)); - Component name = configured ? GuiItems.mini(replace(icon.name(), category, amount)) : Component.text(categoryTitle(category.name(), amount), NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false); - meta.displayName(name); - meta.lore(lore); - meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin)); - meta.getPersistentDataContainer().set(GuiButtonEditorMenu.iconKey(plugin), org.bukkit.persistence.PersistentDataType.STRING, iconKey); - }); - - return item; - } - - private @NotNull ItemStack categoryIcon(@NotNull HeadCategory category, Head iconHead, boolean configured, @NotNull GuiIconConfig icon) { - if (configured) { - return GuiHeadIcons.icon(plugin, icon); - } - - if (iconHead == null) { - return GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category")); - } - - return plugin.itemFactory().create(iconHead); - } - - - private static @NotNull String categoryIconKey(@NotNull String categoryId) { - Objects.requireNonNull(categoryId, "categoryId"); - return "category." + categoryId.trim().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_"); - } - - private static @NotNull String replace(@NotNull String value, @NotNull HeadCategory category, int amount) { - Objects.requireNonNull(value, "value"); - Objects.requireNonNull(category, "category"); - - return value.replace("%name%", category.name()).replace("%id%", category.id()).replace("%count%", String.valueOf(amount)); - } - - private static @NotNull List replaceAll(@NotNull List values, @NotNull HeadCategory category, int amount) { - Objects.requireNonNull(values, "values"); - Objects.requireNonNull(category, "category"); - - List result = new ArrayList<>(); - for (String value : values) { - result.add(replace(value, category, amount)); - } - return List.copyOf(result); - } - - private @NotNull Map categoryIconHeads(@NotNull List categories, boolean includeHidden) { - Objects.requireNonNull(categories, "categories"); - - Map icons = new LinkedHashMap<>(); - Set missing = new HashSet<>(); - for (HeadCategory category : categories) { - missing.add(category.id()); - } - - if (missing.isEmpty()) { - return icons; - } - - List candidateHeads = new ArrayList<>(plugin.headRegistry().heads(includeHidden)); - - for (Head head : candidateHeads) { - String category = head.category(); - if (!missing.contains(category)) { - continue; - } - - icons.put(category, head); - missing.remove(category); - if (missing.isEmpty()) { - return icons; - } - } - - for (Head head : plugin.headRegistry().customHeads().list()) { - String category = head.category(); - if (!missing.contains(category)) { - continue; - } - - icons.put(category, head); - missing.remove(category); - if (missing.isEmpty()) { - return icons; - } + private int visibleCategories(@NotNull GuiContext context) { + Player player = player(context); + if (player == null) { + return 0; } - return icons; + return visibleCategories(player); } - private @NotNull List categories(@NotNull Player player) { + private int visibleCategories(@NotNull Player player) { Objects.requireNonNull(player, "player"); - return plugin.headRegistry().categories().stream().filter(category -> Permissions.canViewCategory(player, category.id())).sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)).toList(); - } - - - private @NotNull String categoryTitle(@NotNull String name, int amount) { - return plugin.guiConfig().text("category-count", "%name% (%count%)").replace("%name%", name).replace("%count%", String.valueOf(amount)); + return (int) plugin.headRegistry().categories().stream().filter(category -> Permissions.canViewCategory(player, category.id())).count(); } private int slot(@NotNull String key, int fallback) { return plugin.guiConfig().slot(key, fallback); } - private static int categoryPage(@NotNull GuiContext context) { - Integer value = context.state().get(CATEGORY_PAGE_STATE_KEY, Integer.class); - if (value == null || value < 0) { - return 0; - } - - return value; - } - - private static void setCategoryPage(@NotNull GuiContext context, int page) { - context.state().put(CATEGORY_PAGE_STATE_KEY, Math.max(0, page)); - } - - private static int pageCount(int entries) { - if (entries <= 0) { - return 1; - } - - return (int) Math.ceil((double) entries / (double) CATEGORY_SLOTS.length); - } - private static Player player(@NotNull GuiContext context) { Objects.requireNonNull(context, "context"); return Bukkit.getPlayer(context.source().viewerId()); @@ -540,15 +259,6 @@ private static void sendSearchCancelled(@NotNull Player player) { player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY)); } - private static @NotNull SearchRequest browseRequest() { - return new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false); - } - - private static @NotNull SearchRequest categoryRequest(@NotNull HeadCategory category) { - Objects.requireNonNull(category, "category"); - return new SearchRequest("", Set.of(), Set.of(category.id()), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, true); - } - private static void set(@NotNull PaperPageBuilder page, @NotNull Set reservedSlots, int slot, @NotNull ItemElement element) { page.set(slot, element); reservedSlots.add(slot); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java index dd8fe0a..33302bc 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java @@ -33,7 +33,7 @@ public final class MaterialSelectionMenu { private static final int SLOT_BACK = 45; private static final int SLOT_PREVIOUS = 48; private static final int SLOT_TYPE = 49; - private static final int SLOT_NEXT = 53; + private static final int SLOT_NEXT = 50; private static final String ACTION_BACK = "back"; private static final String ACTION_PREVIOUS = "previous"; private static final String ACTION_NEXT = "next"; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java index 15f9d94..7e25888 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java @@ -7,6 +7,7 @@ import io.github.silentdevelopment.grafik.paper.page.PaperPage; import io.github.silentdevelopment.grafik.paper.page.PaperPageBuilder; import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory; +import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.model.HeadCategory; import io.github.silentdevelopment.headdb.model.HeadCollection; import io.github.silentdevelopment.headdb.model.HeadTag; @@ -77,7 +78,7 @@ public SearchFilterPageFactory(@NotNull HeadDBPlugin plugin, @NotNull Mode mode) set(page, reservedSlots, SLOT_BACK, backButton()); - if (player == null || !Permissions.has(player, Permissions.GUI_FILTER)) { + if (player == null || !Permissions.has(player, Permissions.GUI_FILTER) || !Permissions.has(player, mode.permission())) { set(page, reservedSlots, 22, deniedButton()); GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots); return page.build(); @@ -226,9 +227,9 @@ private boolean hasSelectedFilter(@NotNull SearchRequest request) { "info", GuiItems.name(mode.title(), NamedTextColor.GOLD), List.of( - GuiItems.lore("Entries: " + entries, NamedTextColor.GRAY), - GuiItems.lore("Selected: " + selectedCount(request), NamedTextColor.GRAY), - GuiItems.lore("Page: " + (pageIndex + 1) + " / " + Math.max(1, pages), NamedTextColor.GRAY) + GuiItems.idDetail("Entries", entries), + GuiItems.idDetail("Selected", selectedCount(request)), + GuiItems.idDetail("Page", (pageIndex + 1) + " / " + Math.max(1, pages)) ), ignored -> {} ); @@ -249,7 +250,7 @@ private int selectedCount(@NotNull SearchRequest request) { "filter-category", GuiItems.name("Category Scope", NamedTextColor.GOLD), List.of( - GuiItems.lore("Current: " + request.category(), NamedTextColor.GRAY), + GuiItems.idDetail("Current", request.category()), GuiItems.lore("Use tag or collection filters inside this category.", NamedTextColor.DARK_GRAY) ), ignored -> {} @@ -286,26 +287,51 @@ private int selectedCount(@NotNull SearchRequest request) { } private @NotNull List entries(@NotNull Player player) { - return switch (mode) { - case CATEGORY -> plugin.headRegistry().categories() - .stream() + if (mode == Mode.CATEGORY) { + return plugin.headRegistry().categories().stream() .filter(category -> Permissions.canViewCategory(player, category.id())) .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)) .map(category -> new FilterEntry(category.id(), category.name())) .toList(); + } - case TAGS -> plugin.headRegistry().tags() - .stream() + if (mode == Mode.TAGS) { + Set visibleTags = visibleTagIds(player); + return plugin.headRegistry().tags().stream() + .filter(tag -> plugin.adminModes().enabled(player) || visibleTags.contains(tag.id())) .sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER)) .map(tag -> new FilterEntry(tag.id(), tag.name())) .toList(); + } - case COLLECTIONS -> plugin.headRegistry().collections() - .stream() - .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)) - .map(collection -> new FilterEntry(collection.id(), collection.name())) - .toList(); - }; + Set visibleCollections = visibleCollectionIds(player); + return plugin.headRegistry().collections().stream() + .filter(collection -> plugin.adminModes().enabled(player) || visibleCollections.contains(collection.id())) + .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)) + .map(collection -> new FilterEntry(collection.id(), collection.name())) + .toList(); + } + + + private @NotNull Set visibleTagIds(@NotNull Player player) { + Set tags = new HashSet<>(); + for (Head head : visibleHeads(player)) { + tags.addAll(head.tags()); + } + return Set.copyOf(tags); + } + + private @NotNull Set visibleCollectionIds(@NotNull Player player) { + Set collections = new HashSet<>(); + for (Head head : visibleHeads(player)) { + collections.addAll(head.collections()); + } + return Set.copyOf(collections); + } + + private @NotNull List visibleHeads(@NotNull Player player) { + boolean includeHidden = plugin.adminModes().enabled(player); + return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList(); } private int pageIndex(@NotNull GuiContext context) { @@ -348,20 +374,22 @@ private record FilterEntry(@NotNull String id, @NotNull String name) {} public enum Mode { - CATEGORY(CATEGORY_KEY, "Category Filters", "Categories", "headdb.search.filter.category-page"), - TAGS(TAGS_KEY, "Tag Filters", "Tags", "headdb.search.filter.tags-page"), - COLLECTIONS(COLLECTIONS_KEY, "Collection Filters", "Collections", "headdb.search.filter.collections-page"); + CATEGORY(CATEGORY_KEY, "Category Filters", "Categories", "headdb.search.filter.category-page", Permissions.GUI_FILTER_CATEGORIES), + TAGS(TAGS_KEY, "Tag Filters", "Tags", "headdb.search.filter.tags-page", Permissions.GUI_FILTER_TAGS), + COLLECTIONS(COLLECTIONS_KEY, "Collection Filters", "Collections", "headdb.search.filter.collections-page", Permissions.GUI_FILTER_COLLECTIONS); private final GKey key; private final String title; private final String label; private final String stateKey; + private final String permission; - Mode(@NotNull GKey key, @NotNull String title, @NotNull String label, @NotNull String stateKey) { + Mode(@NotNull GKey key, @NotNull String title, @NotNull String label, @NotNull String stateKey, @NotNull String permission) { this.key = key; this.title = title; this.label = label; this.stateKey = stateKey; + this.permission = permission; } public @NotNull GKey key() { @@ -379,5 +407,9 @@ public enum Mode { public @NotNull String stateKey() { return stateKey; } + + public @NotNull String permission() { + return permission; + } } } \ No newline at end of file diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java index cd05e07..4b9d1cd 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java @@ -14,25 +14,38 @@ public final class SearchMenu implements PaperDynamicGuiDefinition { private static final GKey KEY = GKey.of("headdb_search"); + private static final GKey ADVANCED_KEY = GKey.of("headdb_search_advanced"); + private final GKey key; private final HeadDBPlugin plugin; private final HeadItemFactory itemFactory; private final SearchResultCache searchResultCache; + private final GKey initialPage; public SearchMenu(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFactory, @NotNull SearchResultCache searchResultCache) { + this(KEY, plugin, itemFactory, searchResultCache, SearchPageFactory.KEY); + } + + private SearchMenu(@NotNull GKey key, @NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFactory, @NotNull SearchResultCache searchResultCache, @NotNull GKey initialPage) { + this.key = Objects.requireNonNull(key, "key"); this.plugin = Objects.requireNonNull(plugin, "plugin"); this.itemFactory = Objects.requireNonNull(itemFactory, "itemFactory"); this.searchResultCache = Objects.requireNonNull(searchResultCache, "searchResultCache"); + this.initialPage = Objects.requireNonNull(initialPage, "initialPage"); + } + + public static @NotNull SearchMenu advanced(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFactory, @NotNull SearchResultCache searchResultCache) { + return new SearchMenu(ADVANCED_KEY, plugin, itemFactory, searchResultCache, SearchOptionsPageFactory.KEY); } @Override public @NotNull GKey key() { - return KEY; + return key; } @Override public void configure(@NotNull PaperDynamicGuiConfigurator gui) { - gui.initialPage(SearchPageFactory.KEY); + gui.initialPage(initialPage); gui.page(new SearchPageFactory(plugin, itemFactory, searchResultCache)); gui.page(new SearchOptionsPageFactory(plugin)); gui.page(new SearchFilterPageFactory(plugin, SearchFilterPageFactory.Mode.CATEGORY)); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java index 1c0d976..a18840c 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java @@ -6,11 +6,29 @@ import java.util.Objects; import java.util.UUID; -public record SearchMenuState(@NotNull UUID viewerId, @NotNull SearchRequest request) { +public record SearchMenuState( + @NotNull UUID viewerId, + @NotNull SearchRequest request, + @NotNull BackTarget resultBackTarget, + @NotNull BackTarget optionsBackTarget +) { + + public SearchMenuState(@NotNull UUID viewerId, @NotNull SearchRequest request) { + this(viewerId, request, BackTarget.MAIN, BackTarget.RESULTS); + } public SearchMenuState { Objects.requireNonNull(viewerId, "viewerId"); Objects.requireNonNull(request, "request"); + Objects.requireNonNull(resultBackTarget, "resultBackTarget"); + Objects.requireNonNull(optionsBackTarget, "optionsBackTarget"); } -} \ No newline at end of file + public enum BackTarget { + MAIN, + BROWSE, + COLLECTIONS, + TAGS, + RESULTS + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchOptionsPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchOptionsPageFactory.java index 884d80d..3a153ed 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchOptionsPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchOptionsPageFactory.java @@ -8,6 +8,8 @@ import io.github.silentdevelopment.grafik.paper.page.PaperPageBuilder; import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.command.search.SearchParser; import io.github.silentdevelopment.headdb.paper.search.SearchRequest; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; @@ -29,6 +31,7 @@ import java.util.Locale; import java.util.Objects; import java.util.Set; +import java.util.function.Consumer; @SuppressWarnings("UnstableApiUsage") public final class SearchOptionsPageFactory implements PaperPageFactory { @@ -37,14 +40,17 @@ public final class SearchOptionsPageFactory implements PaperPageFactorybutton(plugin, "empty", "empty", GuiItems.name("No Permission", NamedTextColor.RED), List.of(GuiItems.lore("You cannot change search filters.", NamedTextColor.GRAY)), ignored -> {}); } + private @NotNull ItemElement queryButton(@NotNull SearchRequest request) { + Objects.requireNonNull(request, "request"); + + return GuiHeadIcons.button( + plugin, + "search_query", + "search", + GuiItems.name("Text Query", NamedTextColor.GOLD), + List.of( + GuiItems.metaDetail("Current", request.query().isBlank() ? "none" : request.query()), + GuiItems.lore("Click to enter a text query.", NamedTextColor.GREEN) + ), + context -> { + Player player = player(context); + if (player == null) { + return; + } + + SearchRequest currentRequest = SearchState.request(context); + SearchMenuState.BackTarget backTarget = context.source().optionsBackTarget(); + promptAfterClose(player, Component.text("Enter a search query.", NamedTextColor.GOLD), value -> { + plugin.guis().openAdvancedSearch(player, currentRequest.withQuery(value), backTarget); + }, () -> { + player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY)); + plugin.guis().openAdvancedSearch(player, currentRequest, backTarget); + }); + } + ); + } + + private @NotNull ItemElement idsButton(@NotNull SearchRequest request) { + Objects.requireNonNull(request, "request"); + + return GuiHeadIcons.button( + plugin, + "filter_ids", + "filter-ids", + GuiItems.name("Head ID Filters", NamedTextColor.GOLD), + List.of( + GuiItems.idDetail("Selected", request.ids().size()), + GuiItems.lore("Click to enter comma-separated IDs.", NamedTextColor.GREEN), + GuiItems.lore("Use remote:, custom:, player:, or bare remote IDs.", NamedTextColor.DARK_GRAY) + ), + context -> { + Player player = player(context); + if (player == null) { + return; + } + + SearchRequest currentRequest = SearchState.request(context); + SearchMenuState.BackTarget backTarget = context.source().optionsBackTarget(); + promptAfterClose(player, Component.text("Enter comma-separated head IDs.", NamedTextColor.GOLD), value -> { + try { + Set ids = value.isBlank() ? Set.of() : SearchParser.headIds(value); + plugin.guis().openAdvancedSearch(player, currentRequest.withIds(ids), backTarget); + } catch (IllegalArgumentException exception) { + player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.guis().openAdvancedSearch(player, currentRequest, backTarget); + } + }, () -> { + player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY)); + plugin.guis().openAdvancedSearch(player, currentRequest, backTarget); + }); + } + ); + } + + + private void promptAfterClose(@NotNull Player player, @NotNull Component message, @NotNull Consumer input, @NotNull Runnable cancel) { + player.closeInventory(); + player.getScheduler().run(plugin, task -> plugin.prompts().request(player, message, input, cancel), () -> {}); + } + private @NotNull ItemElement sortCycleButton(@NotNull SearchRequest request) { Objects.requireNonNull(request, "request"); @@ -201,7 +290,8 @@ public SearchOptionsPageFactory(@NotNull HeadDBPlugin plugin) { "clear-filters", GuiItems.name("Clear Filters", NamedTextColor.RED), List.of( - GuiItems.idDetail("Categories", request.categoryLocked() ? "locked" : request.categories().size()), + GuiItems.idDetail("IDs", request.ids().size()), + GuiItems.idDetail("Categories", request.categoryLocked() ? "scoped" : request.categories().size()), GuiItems.idDetail("Tags", request.tags().size()), GuiItems.idDetail("Collections", request.collections().size()), GuiItems.lore("Click to clear selected filters.", NamedTextColor.GREEN) @@ -224,6 +314,7 @@ public SearchOptionsPageFactory(@NotNull HeadDBPlugin plugin) { GuiItems.name("Current Search", NamedTextColor.GOLD), List.of( GuiItems.metaDetail("Query", request.query().isBlank() ? "none" : request.query()), + GuiItems.idDetail("IDs", request.ids().size()), GuiItems.idDetail("Categories", categorySummary(request)), GuiItems.idDetail("Tags", request.tags().size()), GuiItems.idDetail("Collections", request.collections().size()), @@ -233,15 +324,36 @@ public SearchOptionsPageFactory(@NotNull HeadDBPlugin plugin) { ); } - private @NotNull ItemElement backButton() { + private @NotNull ItemElement executeButton() { return GuiHeadIcons.button( plugin, - "back", - "back", + "execute_search", + "confirm-yes", + GuiItems.name("Run Search", NamedTextColor.GREEN), + List.of(GuiItems.lore("Open the matching heads.", NamedTextColor.GRAY)), context -> context.openPage(SearchPageFactory.KEY) ); } + private @NotNull ItemElement backButton() { + return GuiHeadIcons.button(plugin, "back", "back", context -> openBack(context, context.source().optionsBackTarget())); + } + + private void openBack(@NotNull GuiContext context, @NotNull SearchMenuState.BackTarget target) { + Player player = player(context); + if (player == null) { + return; + } + + switch (target) { + case BROWSE -> plugin.guis().openBrowse(player); + case COLLECTIONS -> plugin.guis().openCollections(player); + case TAGS -> plugin.guis().openTags(player); + case RESULTS -> context.openPage(SearchPageFactory.KEY); + case MAIN -> plugin.guis().openMain(player); + } + } + private static @NotNull List sortLore(@NotNull HeadSort selected) { Objects.requireNonNull(selected, "selected"); @@ -302,7 +414,7 @@ public SearchOptionsPageFactory(@NotNull HeadDBPlugin plugin) { Objects.requireNonNull(request, "request"); if (request.categoryLocked()) { - return request.category() + " (locked)"; + return request.category(); } if (request.categories().isEmpty()) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java index 182e2e9..1c8d76a 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java @@ -9,6 +9,8 @@ import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory; import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.model.HeadCategory; +import io.github.silentdevelopment.headdb.model.HeadCollection; +import io.github.silentdevelopment.headdb.model.HeadTag; import io.github.silentdevelopment.headdb.model.HeadId; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; @@ -49,6 +51,7 @@ public final class SearchPageFactory implements PaperPageFactory page, @No return GuiHeadIcons.button(plugin, "sort_filter", "sort-filter", GuiItems.name("Sort / Filter", NamedTextColor.GOLD), List.of( GuiItems.metaDetail("Sort", request.sort() + " " + request.direction()), + GuiItems.idDetail("IDs", request.ids().size()), GuiItems.idDetail("Categories", categoryLabel(request)), GuiItems.idDetail("Tags", request.tags().size()), GuiItems.idDetail("Collections", request.collections().size()), @@ -173,9 +177,6 @@ private void renderControls(@NotNull PaperPageBuilder page, @No List lore = meta.lore() == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(meta.lore()); lore.add(Component.empty()); lore.add(GuiItems.idDetail("ID", headIdLabel(head.id()))); - lore.add(GuiItems.metaDetail("Category", head.category())); - lore.add(GuiItems.metaDetail("Tags", head.tags().size())); - lore.add(GuiItems.metaDetail("Collections", head.collections().size())); lore.add(GuiItems.lore("Press Q to edit.", NamedTextColor.GRAY)); meta.lore(lore); }); @@ -183,15 +184,22 @@ private void renderControls(@NotNull PaperPageBuilder page, @No } private @NotNull ItemElement backButton() { - return GuiHeadIcons.button(plugin, "back", "back", context -> { - Player player = player(context); + return GuiHeadIcons.button(plugin, "back", "back", context -> openBack(context, context.source().resultBackTarget())); + } - if (player == null) { - return; - } + private void openBack(@NotNull GuiContext context, @NotNull SearchMenuState.BackTarget target) { + Player player = player(context); + if (player == null) { + return; + } - plugin.guis().openMain(player); - }); + switch (target) { + case BROWSE -> plugin.guis().openBrowse(player); + case COLLECTIONS -> plugin.guis().openCollections(player); + case TAGS -> plugin.guis().openTags(player); + case RESULTS -> context.openPage(SearchPageFactory.KEY); + case MAIN -> plugin.guis().openMain(player); + } } private @NotNull ItemElement previousButton(int currentPage) { @@ -209,7 +217,21 @@ private void renderControls(@NotNull PaperPageBuilder page, @No } private @NotNull ItemElement summaryButton(@NotNull SearchRequest request, @NotNull HeadQueryResult result, int currentPage) { - return GuiHeadIcons.button(plugin, "summary", "info", GuiItems.name("Search Summary", NamedTextColor.GOLD), summaryLore(request, result, currentPage), ignored -> {}); + return GuiHeadIcons.button(plugin, "summary", "info", GuiItems.name(summaryTitle(request), NamedTextColor.GOLD), summaryLore(request, result, currentPage), ignored -> {}); + } + + private static @NotNull String summaryTitle(@NotNull SearchRequest request) { + Objects.requireNonNull(request, "request"); + + if (request.categoryLocked()) { + return "Category Summary"; + } + + if (request.isEmpty()) { + return "Browse Summary"; + } + + return "Search Summary"; } private @NotNull ItemElement emptyButton() { @@ -273,7 +295,29 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea private @NotNull HeadQueryResult search(Player player, @NotNull SearchRequest request, int page) { Objects.requireNonNull(request, "request"); boolean includeHidden = player != null && plugin.adminModes().enabled(player); - return searchResultCache.search(plugin.headRegistry(), request, page, PAGE_SIZE, includeHidden); + SearchRequest scopedRequest = scopedRequest(player, request); + return searchResultCache.search(plugin.headRegistry(), scopedRequest, page, PAGE_SIZE, includeHidden); + } + + private @NotNull SearchRequest scopedRequest(Player player, @NotNull SearchRequest request) { + Objects.requireNonNull(request, "request"); + + if (player == null || request.categoryLocked() || Permissions.canViewAllCategories(player)) { + return request; + } + + Set visibleCategories = visibleCategories(player); + if (request.categories().isEmpty()) { + return request.withCategoryFilters(visibleCategories.isEmpty() ? Set.of(NO_VISIBLE_CATEGORY) : visibleCategories); + } + + Set scopedCategories = request.categories().stream().filter(category -> visibleCategories.contains(category)).collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)); + return request.withCategoryFilters(scopedCategories.isEmpty() ? Set.of(NO_VISIBLE_CATEGORY) : scopedCategories); + } + + private @NotNull Set visibleCategories(@NotNull Player player) { + Objects.requireNonNull(player, "player"); + return plugin.headRegistry().categories().stream().map(HeadCategory::id).filter(category -> Permissions.canViewCategory(player, category)).collect(java.util.stream.Collectors.toUnmodifiableSet()); } private @NotNull Component title(Player viewer, @NotNull SearchRequest request, @NotNull HeadQueryResult result, int currentPage) { @@ -295,6 +339,14 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea return request.query(); } + if (request.tags().size() == 1 && request.ids().isEmpty() && request.categories().isEmpty() && request.collections().isEmpty()) { + return tagName(request.tags().iterator().next()); + } + + if (request.collections().size() == 1 && request.ids().isEmpty() && request.categories().isEmpty() && request.tags().isEmpty()) { + return collectionName(request.collections().iterator().next()); + } + if (request.hasFilters()) { return "Filtered Heads"; } @@ -308,6 +360,18 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea return plugin.headRegistry().categories().stream().filter(category -> category.id().equalsIgnoreCase(categoryId)).map(HeadCategory::name).findFirst().orElse(categoryId); } + private @NotNull String collectionName(@NotNull String collectionId) { + Objects.requireNonNull(collectionId, "collectionId"); + + return plugin.headRegistry().collections().stream().filter(collection -> collection.id().equalsIgnoreCase(collectionId)).map(HeadCollection::name).findFirst().orElse(collectionId); + } + + private @NotNull String tagName(@NotNull String tagId) { + Objects.requireNonNull(tagId, "tagId"); + + return plugin.headRegistry().tags().stream().filter(tag -> tag.id().equalsIgnoreCase(tagId)).map(HeadTag::name).findFirst().orElse(tagId); + } + private @NotNull List summaryLore(@NotNull SearchRequest request, @NotNull HeadQueryResult result, int currentPage) { Objects.requireNonNull(request, "request"); Objects.requireNonNull(result, "result"); @@ -320,6 +384,7 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea lore.add(GuiItems.idDetail("Results", result.total())); lore.add(GuiItems.idDetail("Page", currentPage + " / " + Math.max(1, result.totalPages()))); + lore.add(GuiItems.idDetail("IDs", request.ids().size())); lore.add(GuiItems.idDetail("Categories", categoryLabel(request))); lore.add(GuiItems.idDetail("Tags", request.tags().size())); lore.add(GuiItems.idDetail("Collections", request.collections().size())); @@ -332,7 +397,7 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea Objects.requireNonNull(request, "request"); if (request.categoryLocked()) { - return request.category() + " (locked)"; + return request.category(); } if (request.categories().isEmpty()) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java new file mode 100644 index 0000000..b3cf9f9 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java @@ -0,0 +1,162 @@ +package io.github.silentdevelopment.headdb.paper.gui.settings; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class CommandConfirmMenu { + + private static final int SIZE = 27; + private static final String ACTION_CONFIRM = "confirm"; + private static final String ACTION_CANCEL = "cancel"; + + private CommandConfirmMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String title, @NotNull String command, @NotNull String permission) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(permission, "permission"); + + if (!Permissions.has(player, permission)) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return; + } + + Holder holder = new Holder(command, permission); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title, false)); + holder.inventory(inventory); + fill(inventory); + inventory.setItem(11, action(plugin, "confirm-yes", ACTION_CONFIRM, command)); + inventory.setItem(15, action(plugin, "confirm-no", ACTION_CANCEL, command)); + player.openInventory(inventory); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder() instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + if (action.get().equals(ACTION_CANCEL)) { + plugin.guis().openMain(player); + return true; + } + + if (!action.get().equals(ACTION_CONFIRM)) { + return true; + } + + if (!Permissions.has(player, holder.permission())) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return true; + } + + player.closeInventory(); + player.performCommand(holder.command()); + return true; + } + + private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String command) { + ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail("Command", "/" + command))); + stamp(plugin, item, action); + return item; + } + + private static void fill(@NotNull Inventory inventory) { + ItemStack item = GuiItems.item(Material.BLACK_STAINED_GLASS_PANE, Component.empty(), List.of()); + for (int slot = 0; slot < inventory.getSize(); slot++) { + inventory.setItem(slot, item.clone()); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "command_confirm_action"); + } + + private static final class Holder implements InventoryHolder { + private final String command; + private final String permission; + private Inventory inventory; + + private Holder(@NotNull String command, @NotNull String permission) { + this.command = Objects.requireNonNull(command, "command"); + this.permission = Objects.requireNonNull(permission, "permission"); + } + + private @NotNull String command() { + return command; + } + + private @NotNull String permission() { + return permission; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java index edd541a..d98c852 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java @@ -32,8 +32,9 @@ public final class SettingsPageFactory implements PaperPageFactory { public static final GKey KEY = GKey.of("settings"); - private static final int ROWS = 6; - private static final int SLOT_DENIED = 22; + private static final int ROWS_FULL = 6; + private static final int ROWS_LANGUAGE_ONLY = 1; + private static final int SLOT_DENIED = 4; private final HeadDBPlugin plugin; @@ -51,49 +52,68 @@ public SettingsPageFactory(@NotNull HeadDBPlugin plugin) { Objects.requireNonNull(context, "context"); Objects.requireNonNull(page, "page"); - page.type(MenuType.GENERIC_9X6); Player player = player(context); - boolean adminMode = player != null && plugin.adminModes().enabled(player); - page.title(GuiTitles.title("HeadDB Settings", adminMode)); + boolean languageOnly = player != null && isLanguageOnly(player); + int rows = languageOnly ? ROWS_LANGUAGE_ONLY : ROWS_FULL; + page.type(languageOnly ? MenuType.GENERIC_9X1 : MenuType.GENERIC_9X6); + page.title(GuiTitles.title(plugin.guiConfig().text("title.settings", "Settings"), false)); Set reservedSlots = new HashSet<>(); - set(page, reservedSlots, slot("settings.back", 45), backButton()); if (player == null || !Permissions.has(player, Permissions.GUI_SETTINGS)) { set(page, reservedSlots, SLOT_DENIED, deniedButton()); - GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots); + GuiItems.fillEmpty(plugin, page, rows, reservedSlots); + return page.build(); + } + + if (languageOnly) { + set(page, reservedSlots, slot("settings.language-only", 4), languagesButton()); + GuiItems.fillEmpty(plugin, page, rows, reservedSlots); return page.build(); } + set(page, reservedSlots, slot("settings.back", 45), backButton()); + if (Permissions.has(player, Permissions.GUI_SETTINGS_LANGUAGE)) { - set(page, reservedSlots, slot("settings.language", 20), languagesButton()); + set(page, reservedSlots, slot("settings.language", 13), languagesButton()); } if (Permissions.has(player, Permissions.GUI_ADMIN_MODE)) { - set(page, reservedSlots, slot("settings.admin-mode", 13), adminModeButton(player)); + set(page, reservedSlots, slot("settings.admin-mode", 31), adminModeButton(player)); } - if (Permissions.has(player, Permissions.DEBUG)) { - set(page, reservedSlots, slot("settings.debug", 24), commandButton("debug", "debug", Permissions.DEBUG, "hdb debug")); + set(page, reservedSlots, slot("settings.debug", 38), commandButton("debug", "debug", Permissions.DEBUG, "hdb debug")); } if (Permissions.has(player, Permissions.VERIFY)) { - set(page, reservedSlots, slot("settings.verify", 33), commandButton("verify", "verify", Permissions.VERIFY, "hdb verify")); + set(page, reservedSlots, slot("settings.verify", 39), commandButton("verify", "verify", Permissions.VERIFY, "hdb verify")); } if (Permissions.has(player, Permissions.REFRESH)) { - set(page, reservedSlots, slot("settings.refresh", 42), commandButton("refresh", "refresh", Permissions.REFRESH, "hdb refresh")); + set(page, reservedSlots, slot("settings.refresh", 41), confirmCommandButton("refresh", "refresh", Permissions.REFRESH, "hdb refresh", plugin.guiConfig().text("title.confirm-refresh", "Confirm Refresh"))); } if (Permissions.has(player, Permissions.RELOAD)) { - set(page, reservedSlots, slot("settings.reload", 40), commandButton("reload", "reload", Permissions.RELOAD, "hdb reload")); + set(page, reservedSlots, slot("settings.reload", 42), confirmCommandButton("reload", "reload", Permissions.RELOAD, "hdb reload", plugin.guiConfig().text("title.confirm-reload", "Confirm Reload"))); } - GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots); + GuiItems.fillEmpty(plugin, page, rows, reservedSlots); return page.build(); } + private boolean isLanguageOnly(@NotNull Player player) { + if (!Permissions.has(player, Permissions.GUI_SETTINGS_LANGUAGE)) { + return false; + } + + return !Permissions.has(player, Permissions.GUI_ADMIN_MODE) + && !Permissions.has(player, Permissions.DEBUG) + && !Permissions.has(player, Permissions.VERIFY) + && !Permissions.has(player, Permissions.REFRESH) + && !Permissions.has(player, Permissions.RELOAD); + } + private @NotNull ItemElement backButton() { return GuiHeadIcons.button(plugin, "back", "back", context -> context.openPage(MainPageFactory.KEY)); } @@ -156,6 +176,28 @@ public SettingsPageFactory(@NotNull HeadDBPlugin plugin) { }); } + private @NotNull ItemElement confirmCommandButton(@NotNull String elementKey, @NotNull String iconKey, @NotNull String permission, @NotNull String command, @NotNull String title) { + Objects.requireNonNull(elementKey, "elementKey"); + Objects.requireNonNull(iconKey, "iconKey"); + Objects.requireNonNull(permission, "permission"); + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(title, "title"); + + return GuiHeadIcons.button(plugin, elementKey, iconKey, context -> { + Player player = player(context); + if (player == null) { + return; + } + + if (!Permissions.has(player, permission)) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + return; + } + + CommandConfirmMenu.open(plugin, player, title, command, permission); + }); + } + private @NotNull ItemElement deniedButton() { return GuiHeadIcons.button(plugin, "settings_denied", "no-permission", GuiItems.name("No Permission", NamedTextColor.RED), List.of(GuiItems.lore("You cannot open this settings page.", NamedTextColor.GRAY)), ignored -> {}); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/DefaultHeadItemFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/DefaultHeadItemFactory.java index d04f4bf..fb02948 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/DefaultHeadItemFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/DefaultHeadItemFactory.java @@ -214,6 +214,16 @@ private static void validateTextureHash(@NotNull Head head, String textureHash) // Registry is unavailable only during very early bootstrap. } + if (!lore.isEmpty()) { + lore.add(Component.empty()); + } + + if (!head.id().isPlayer()) { + lore.add(line("Category", head.category())); + lore.add(line("Tags", join(head.tags()))); + lore.add(line("Collections", join(head.collections()))); + } + return cleanLore(lore); } @@ -241,7 +251,7 @@ private static void validateTextureHash(@NotNull Head head, String textureHash) private static @NotNull Component line(@NotNull String key, @NotNull String value) { return Component.text(key + ": ", NamedTextColor.GRAY) - .append(Component.text(value, NamedTextColor.WHITE)) + .append(Component.text(value, NamedTextColor.GOLD)) .decoration(TextDecoration.ITALIC, false); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java index e6f0c27..11260fd 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java @@ -15,6 +15,7 @@ import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverride; import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverrideStore; import io.github.silentdevelopment.headdb.paper.local.player.PlayerHeadService; +import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyService; import io.github.silentdevelopment.headdb.query.HeadQuery; import io.github.silentdevelopment.headdb.query.HeadQueryResult; import io.github.silentdevelopment.headdb.query.HeadSort; @@ -41,13 +42,17 @@ public final class HeadRegistry implements io.github.silentdevelopment.headdb.re private final RemoteHeadOverrideStore overrideStore; private final CustomHeadStore customHeadStore; private final PlayerHeadService playerHeadService; + private final CustomTaxonomyService customTagService; + private final CustomTaxonomyService customCollectionService; private final HeadOverrideMerger merger; - public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull RemoteHeadOverrideStore overrideStore, @NotNull CustomHeadStore customHeadStore, @NotNull PlayerHeadService playerHeadService) { + public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull RemoteHeadOverrideStore overrideStore, @NotNull CustomHeadStore customHeadStore, @NotNull PlayerHeadService playerHeadService, @NotNull CustomTaxonomyService customTagService, @NotNull CustomTaxonomyService customCollectionService) { this.remoteDatabase = Objects.requireNonNull(remoteDatabase, "remoteDatabase"); this.overrideStore = Objects.requireNonNull(overrideStore, "overrideStore"); this.customHeadStore = Objects.requireNonNull(customHeadStore, "customHeadStore"); this.playerHeadService = Objects.requireNonNull(playerHeadService, "playerHeadService"); + this.customTagService = Objects.requireNonNull(customTagService, "customTagService"); + this.customCollectionService = Objects.requireNonNull(customCollectionService, "customCollectionService"); this.merger = new HeadOverrideMerger(); } @@ -162,19 +167,27 @@ public boolean hidden(@NotNull HeadId id) { } public @NotNull List categories() { - Map categories = new LinkedHashMap<>(); + Map remoteCategories = new LinkedHashMap<>(); for (HeadCategory category : remoteDatabase.categories()) { - categories.put(category.id(), category); + remoteCategories.put(category.id(), category); } + + Map localCategories = new LinkedHashMap<>(); for (Head head : customHeadStore.list()) { - categories.putIfAbsent(head.category(), new HeadCategory(head.category(), displayName(head.category()), "Local custom category.")); + localCategories.putIfAbsent(head.category(), new HeadCategory(head.category(), displayName(head.category()), "Local custom category.")); } for (RemoteHeadOverride override : overrideStore.list()) { - if (override.category() != null) { - categories.putIfAbsent(override.category(), new HeadCategory(override.category(), displayName(override.category()), "Local override category.")); + if (override.category() == null) { + continue; } + + localCategories.putIfAbsent(override.category(), new HeadCategory(override.category(), displayName(override.category()), "Local override category.")); } - return categories.values().stream().sorted(Comparator.comparing(HeadCategory::id)).toList(); + + List categories = new ArrayList<>(); + categories.addAll(remoteCategories.values().stream().sorted(Comparator.comparing(HeadCategory::id)).toList()); + categories.addAll(localCategories.values().stream().filter(category -> !remoteCategories.containsKey(category.id())).sorted(Comparator.comparing(HeadCategory::id)).toList()); + return List.copyOf(categories); } public @NotNull List tags() { @@ -197,6 +210,9 @@ public boolean hidden(@NotNull HeadId id) { } } } + for (var tag : customTagService.list()) { + tags.put(tag.id(), new HeadTag(tag.id(), tag.name(), tag.description())); + } return tags.values().stream().sorted(Comparator.comparing(HeadTag::id)).toList(); } @@ -210,6 +226,9 @@ public boolean hidden(@NotNull HeadId id) { collections.putIfAbsent(collection, new HeadCollection(collection, displayName(collection), "Local collection.")); } } + for (var collection : customCollectionService.list()) { + collections.put(collection.id(), new HeadCollection(collection.id(), collection.name(), collection.description())); + } return collections.values().stream().sorted(Comparator.comparing(HeadCollection::id)).toList(); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CustomHeadStore.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CustomHeadStore.java index e8b27bc..940b34b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CustomHeadStore.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CustomHeadStore.java @@ -12,13 +12,13 @@ public interface CustomHeadStore { @NotNull Optional findStored(@NotNull HeadId id); default @NotNull Optional find(@NotNull HeadId id) { - return findStored(id).map(StoredCustomHead::toHead); + return findStored(id).filter(head -> !head.draft()).map(StoredCustomHead::toHead); } @NotNull Collection listStored(); default @NotNull Collection list() { - return listStored().stream().map(StoredCustomHead::toHead).toList(); + return listStored().stream().filter(head -> !head.draft()).map(StoredCustomHead::toHead).toList(); } void save(@NotNull StoredCustomHead head); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/StoredCustomHead.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/StoredCustomHead.java index 620b81f..71255eb 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/StoredCustomHead.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/StoredCustomHead.java @@ -23,9 +23,14 @@ public record StoredCustomHead( @NotNull String category, @NotNull Instant createdAt, @NotNull Instant updatedAt, - @Nullable UUID createdBy + @Nullable UUID createdBy, + boolean draft ) { + public StoredCustomHead(@NotNull String id, @NotNull String name, @NotNull String textureHash, @Nullable String textureSignature, @NotNull List lore, @NotNull Set tags, @NotNull Set collections, @NotNull String category, @NotNull Instant createdAt, @NotNull Instant updatedAt, @Nullable UUID createdBy) { + this(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, updatedAt, createdBy, false); + } + public StoredCustomHead { Objects.requireNonNull(id, "id"); Objects.requireNonNull(name, "name"); @@ -64,11 +69,32 @@ public record StoredCustomHead( } public @NotNull Head toHead() { - return new Head(headId(), name, new HeadTexture(textureHash), category, tags, collections); + return new Head(headId(), draft ? "[DRAFT] " + name : name, new HeadTexture(textureHash), category, tags, collections); } public @NotNull StoredCustomHead withName(@NotNull String name) { - return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy); + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); + } + + + public @NotNull StoredCustomHead withDraft(boolean draft) { + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); + } + + public @NotNull StoredCustomHead withCategory(@NotNull String category) { + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); + } + + public @NotNull StoredCustomHead withTags(@NotNull Set tags) { + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); + } + + public @NotNull StoredCustomHead withCollections(@NotNull Set collections) { + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); + } + + public @NotNull StoredCustomHead withLore(@NotNull List lore) { + return new StoredCustomHead(id, name, textureHash, textureSignature, lore, tags, collections, category, createdAt, Instant.now(), createdBy, draft); } private static @NotNull Set normalizeSet(@NotNull Set values) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/storage/StrataLocalStores.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/storage/StrataLocalStores.java index ed35892..8ea08e5 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/storage/StrataLocalStores.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/storage/StrataLocalStores.java @@ -352,7 +352,8 @@ private record StoredCustomHeadRow( @NotNull String category, @NotNull String createdAt, @NotNull String updatedAt, - @Nullable String createdBy + @Nullable String createdBy, + boolean draft ) { private StoredCustomHeadRow { @@ -397,7 +398,8 @@ private record StoredCustomHeadRow( head.category(), instant(head.createdAt()), instant(head.updatedAt()), - uuid(head.createdBy()) + uuid(head.createdBy()), + head.draft() ); } @@ -416,7 +418,8 @@ private record StoredCustomHeadRow( category, created, updated, - uuid(createdBy) + uuid(createdBy), + draft ); } } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyEntry.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyEntry.java new file mode 100644 index 0000000..282e806 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyEntry.java @@ -0,0 +1,67 @@ +package io.github.silentdevelopment.headdb.paper.local.taxonomy; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +public record CustomTaxonomyEntry(@NotNull String id, @NotNull String name, @NotNull String description, @NotNull Instant createdAt, @NotNull Instant updatedAt, @Nullable UUID createdBy) { + + public CustomTaxonomyEntry(@NotNull String id, @NotNull String name, @NotNull String description, @Nullable UUID createdBy) { + this(id, name, description, Instant.now(), Instant.now(), createdBy); + } + + public CustomTaxonomyEntry { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(description, "description"); + + id = normalizeId(id); + name = requireText(name, "Name"); + description = description.trim().isBlank() ? "Local custom entry." : description.trim(); + if (createdAt == null) { + createdAt = Instant.now(); + } + if (updatedAt == null) { + updatedAt = createdAt; + } + } + + public @NotNull CustomTaxonomyEntry withName(@NotNull String name) { + return new CustomTaxonomyEntry(id, name, description, createdAt, Instant.now(), createdBy); + } + + public @NotNull CustomTaxonomyEntry withDescription(@NotNull String description) { + return new CustomTaxonomyEntry(id, name, description, createdAt, Instant.now(), createdBy); + } + + public static @NotNull String normalizeId(@NotNull String value) { + Objects.requireNonNull(value, "value"); + String normalized = value.trim().toLowerCase(Locale.ROOT).replace(' ', '-'); + if (normalized.isBlank()) { + throw new IllegalArgumentException("ID cannot be blank."); + } + + for (int index = 0; index < normalized.length(); index++) { + char c = normalized.charAt(index); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { + continue; + } + throw new IllegalArgumentException("ID contains an invalid character: " + c); + } + + return normalized; + } + + private static @NotNull String requireText(@NotNull String value, @NotNull String name) { + Objects.requireNonNull(value, "value"); + String normalized = value.trim(); + if (normalized.isBlank()) { + throw new IllegalArgumentException(name + " cannot be blank."); + } + return normalized; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java new file mode 100644 index 0000000..a7350ff --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java @@ -0,0 +1,171 @@ +package io.github.silentdevelopment.headdb.paper.local.taxonomy; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.sqlite.SQLiteDataSource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public final class CustomTaxonomyService { + + private final SQLiteDataSource dataSource; + private final String type; + private final String defaultDescription; + + public CustomTaxonomyService(@NotNull Path databaseFile, @NotNull String type, @NotNull String defaultDescription) { + Objects.requireNonNull(databaseFile, "databaseFile"); + this.type = normalizeType(type); + this.defaultDescription = Objects.requireNonNull(defaultDescription, "defaultDescription"); + this.dataSource = dataSource(databaseFile); + createSchema(); + } + + public synchronized @NotNull List list() { + List entries = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, description, created_at, updated_at, created_by FROM headdb_custom_taxonomy WHERE type = ? ORDER BY lower(name) ASC")) { + statement.setString(1, type); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + entries.add(entry(result)); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Failed to list HeadDB custom " + type + " entries.", exception); + } + + entries.sort(Comparator.comparing(CustomTaxonomyEntry::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(entries); + } + + public synchronized @NotNull Optional find(@NotNull String id) { + Objects.requireNonNull(id, "id"); + String normalized = CustomTaxonomyEntry.normalizeId(id); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, description, created_at, updated_at, created_by FROM headdb_custom_taxonomy WHERE type = ? AND id = ?")) { + statement.setString(1, type); + statement.setString(2, normalized); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + + return Optional.of(entry(result)); + } + } catch (SQLException exception) { + throw new IllegalStateException("Failed to read HeadDB custom " + type + " entry " + normalized + ".", exception); + } + } + + public synchronized void save(@NotNull CustomTaxonomyEntry entry) { + Objects.requireNonNull(entry, "entry"); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO headdb_custom_taxonomy(type, id, name, description, created_at, updated_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(type, id) DO UPDATE SET name = excluded.name, description = excluded.description, updated_at = excluded.updated_at, created_by = excluded.created_by")) { + statement.setString(1, type); + statement.setString(2, entry.id()); + statement.setString(3, entry.name()); + statement.setString(4, entry.description()); + statement.setString(5, entry.createdAt().toString()); + statement.setString(6, entry.updatedAt().toString()); + statement.setString(7, entry.createdBy() == null ? null : entry.createdBy().toString()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to save HeadDB custom " + type + " entry " + entry.id() + ".", exception); + } + } + + public synchronized @NotNull CustomTaxonomyEntry create(@NotNull String id, @NotNull String name, @Nullable UUID createdBy) { + CustomTaxonomyEntry entry = new CustomTaxonomyEntry(id, name, defaultDescription, createdBy); + save(entry); + return entry; + } + + public synchronized boolean delete(@NotNull String id) { + Objects.requireNonNull(id, "id"); + String normalized = CustomTaxonomyEntry.normalizeId(id); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("DELETE FROM headdb_custom_taxonomy WHERE type = ? AND id = ?")) { + statement.setString(1, type); + statement.setString(2, normalized); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Failed to delete HeadDB custom " + type + " entry " + normalized + ".", exception); + } + } + + private @NotNull CustomTaxonomyEntry entry(@NotNull ResultSet result) throws SQLException { + String description = result.getString("description"); + return new CustomTaxonomyEntry( + result.getString("id"), + result.getString("name"), + description == null || description.isBlank() ? defaultDescription : description, + instant(result.getString("created_at"), Instant.EPOCH), + instant(result.getString("updated_at"), Instant.EPOCH), + uuid(result.getString("created_by")) + ); + } + + private void createSchema() { + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_taxonomy(type TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, created_by TEXT, PRIMARY KEY(type, id))")) { + statement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to create HeadDB custom taxonomy schema.", exception); + } + } + + private static @NotNull SQLiteDataSource dataSource(@NotNull Path databaseFile) { + try { + Path parent = databaseFile.toAbsolutePath().normalize().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + } catch (Exception exception) { + throw new IllegalStateException("Failed to create HeadDB storage directory for " + databaseFile, exception); + } + + SQLiteDataSource dataSource = new SQLiteDataSource(); + dataSource.setUrl("jdbc:sqlite:" + databaseFile.toAbsolutePath().normalize()); + return dataSource; + } + + private static @NotNull String normalizeType(@NotNull String type) { + String normalized = type.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + throw new IllegalArgumentException("Taxonomy type cannot be blank."); + } + return normalized; + } + + private static @NotNull Instant instant(@Nullable String value, @NotNull Instant fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + + try { + return Instant.parse(value); + } catch (RuntimeException ignored) { + return fallback; + } + } + + private static @Nullable UUID uuid(@Nullable String value) { + if (value == null || value.isBlank()) { + return null; + } + + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/MessageKey.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/MessageKey.java index 434de4d..42d507c 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/MessageKey.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/MessageKey.java @@ -57,7 +57,12 @@ public enum MessageKey { // GUI GUI_LANGUAGE_CHANGED("gui.language.changed"), - GUI_LANGUAGE_RESET("gui.language.reset"); + GUI_LANGUAGE_RESET("gui.language.reset"), + GUI_PRICE_INVALID("gui.price.invalid"), + GUI_PRICE_UPDATED("gui.price.updated"), + GUI_TAXONOMY_CREATED("gui.taxonomy.created"), + GUI_TAXONOMY_DELETED("gui.taxonomy.deleted"), + GUI_TAXONOMY_UNKNOWN("gui.taxonomy.unknown"); private final String path; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/Messages.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/Messages.java index a7e4d38..4629835 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/Messages.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/message/Messages.java @@ -208,7 +208,11 @@ public Messages(@NotNull PaperMessenger messenger) { Component message = messenger.getMessage((Audience) receiver, key.path()); if (message == null) { - message = MINI_MESSAGE.deserialize(fallbackMessages.getOrDefault(key, "Missing HeadDB message: " + key.path())); + String fallback = fallbackMessages.get(key); + if (fallback == null || fallback.isBlank()) { + return Component.empty(); + } + message = MINI_MESSAGE.deserialize(fallback); } return applyPlaceholders(message, placeholders); @@ -307,6 +311,26 @@ public void clearLocale(@NotNull UUID uniqueId) { return render(receiver, MessageKey.GUI_LANGUAGE_RESET); } + public @NotNull Component priceInvalid(@NotNull CommandSender receiver) { + return render(receiver, MessageKey.GUI_PRICE_INVALID); + } + + public @NotNull Component priceUpdated(@NotNull CommandSender receiver, @NotNull String target, @NotNull String price) { + return render(receiver, MessageKey.GUI_PRICE_UPDATED, Map.of("target", target, "price", price)); + } + + public @NotNull Component taxonomyCreated(@NotNull CommandSender receiver, @NotNull String type, @NotNull String name, @NotNull String id) { + return render(receiver, MessageKey.GUI_TAXONOMY_CREATED, Map.of("type", type, "name", name, "id", id)); + } + + public @NotNull Component taxonomyDeleted(@NotNull CommandSender receiver, @NotNull String type, @NotNull String id) { + return render(receiver, MessageKey.GUI_TAXONOMY_DELETED, Map.of("type", type, "id", id)); + } + + public @NotNull Component taxonomyUnknown(@NotNull CommandSender receiver, @NotNull String type, @NotNull String id) { + return render(receiver, MessageKey.GUI_TAXONOMY_UNKNOWN, Map.of("type", type, "id", id)); + } + private static @NotNull String localeName(String value, @NotNull String fallback) { Objects.requireNonNull(fallback, "fallback"); @@ -420,6 +444,11 @@ public void clearLocale(@NotNull UUID uniqueId) { messages.put(MessageKey.GUI_LANGUAGE_CHANGED, "Language changed to {locale}."); messages.put(MessageKey.GUI_LANGUAGE_RESET, "Language reset to the default locale."); + messages.put(MessageKey.GUI_PRICE_INVALID, "Price must be a valid number."); + messages.put(MessageKey.GUI_PRICE_UPDATED, "Updated price for {target} to {price}."); + messages.put(MessageKey.GUI_TAXONOMY_CREATED, "Created custom {type}: {name} ({id})."); + messages.put(MessageKey.GUI_TAXONOMY_DELETED, "Deleted custom {type}: {id}."); + messages.put(MessageKey.GUI_TAXONOMY_UNKNOWN, "Unknown custom {type}: {id}"); return Map.copyOf(messages); } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java index b029972..1517860 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java @@ -21,6 +21,10 @@ public final class Permissions { public static final String RELOAD = "headdb.command.reload"; public static final String UPDATE = "headdb.admin.update"; public static final String SEARCH = "headdb.command.search"; + public static final String TAG_CREATE = "headdb.command.tags.create"; + public static final String TAG_DELETE = "headdb.command.tags.delete"; + public static final String COLLECTION_CREATE = "headdb.command.collections.create"; + public static final String COLLECTION_DELETE = "headdb.command.collections.delete"; public static final String INFO = "headdb.command.info"; public static final String GIVE = "headdb.command.give"; public static final String GIVE_OTHERS = "headdb.command.give.others"; @@ -49,8 +53,14 @@ public final class Permissions { public static final String GUI_MAIN = "headdb.gui.main"; public static final String GUI_BROWSE = "headdb.gui.browse"; + public static final String GUI_BROWSE_MENU = "headdb.gui.browse.menu"; public static final String GUI_SEARCH = "headdb.gui.search"; + public static final String GUI_SEARCH_ADVANCED = "headdb.gui.search.advanced"; public static final String GUI_FILTER = "headdb.gui.filter"; + public static final String GUI_FILTER_IDS = "headdb.gui.filter.ids"; + public static final String GUI_FILTER_CATEGORIES = "headdb.gui.filter.categories"; + public static final String GUI_FILTER_TAGS = "headdb.gui.filter.tags"; + public static final String GUI_FILTER_COLLECTIONS = "headdb.gui.filter.collections"; public static final String GUI_HEAD_TAKE = "headdb.gui.head.take"; public static final String GUI_CATEGORY_VIEW = "headdb.gui.category.view"; public static final String GUI_CATEGORY_OPEN = "headdb.gui.category.open"; @@ -58,6 +68,11 @@ public final class Permissions { public static final String GUI_CUSTOM_HEADS = "headdb.gui.custom-heads"; public static final String GUI_FAVORITES = "headdb.gui.favorites"; public static final String FAVORITES_TOGGLE = "headdb.gui.favorites.toggle"; + public static final String GUI_COLLECTIONS = "headdb.gui.collections"; + public static final String GUI_TAGS = "headdb.gui.tags"; + public static final String GUI_CREATE_HEAD = "headdb.gui.create-head"; + public static final String GUI_CREATE_TAG = "headdb.gui.create-tag"; + public static final String GUI_CREATE_COLLECTION = "headdb.gui.create-collection"; public static final String GUI_MORE_CATEGORIES = "headdb.gui.more-categories"; public static final String GUI_CUSTOM_CATEGORIES_ADMIN = "headdb.gui.more-categories.admin"; public static final String GUI_HIDDEN_HEADS = "headdb.gui.hidden-heads"; diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchRequest.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchRequest.java index d844112..fab9d4e 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchRequest.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchRequest.java @@ -96,6 +96,16 @@ public boolean canChangeCategory() { return new SearchRequest(query, ids, categories, tags, collections, sort, direction, page, limit, categoryLocked); } + public @NotNull SearchRequest withQuery(@NotNull String query) { + Objects.requireNonNull(query, "query"); + return new SearchRequest(query, ids, categories, tags, collections, sort, direction, 1, limit, categoryLocked); + } + + public @NotNull SearchRequest withIds(@NotNull Set ids) { + Objects.requireNonNull(ids, "ids"); + return new SearchRequest(query, ids, categories, tags, collections, sort, direction, 1, limit, categoryLocked); + } + public @NotNull SearchRequest withSort(@NotNull HeadSort sort) { Objects.requireNonNull(sort, "sort"); return new SearchRequest(query, ids, categories, tags, collections, sort, direction, 1, limit, categoryLocked); diff --git a/headdb-platforms/headdb-paper/src/main/resources/gui.yml b/headdb-platforms/headdb-paper/src/main/resources/gui.yml index 37bf626..6cf3a47 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/gui.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/gui.yml @@ -1,4 +1,4 @@ -version: 1 +version: 2 filler: enabled: true material: BLACK_STAINED_GLASS_PANE @@ -30,7 +30,7 @@ icons: type: HEAD head-id: '38870' material: BOOK - name: Information + name: Info lore: - View information. settings: @@ -39,7 +39,7 @@ icons: material: REPEATER name: Settings lore: - - Open HeadDB settings. + - Open Settings languages: type: HEAD head-id: '112081' @@ -89,20 +89,28 @@ icons: name: Close lore: - Close this menu. + browse: + type: HEAD + head-id: '220' + material: PLAYER_HEAD + name: Browse + lore: + - Browse heads. browse-all: type: HEAD head-id: '220' material: PLAYER_HEAD - name: Browse All Heads + name: Browse All lore: - - Open all heads. + - Click to browse all categories. search: type: ITEM head-id: '' material: SPYGLASS name: Search lore: - - Search HeadDB using chat input. + - Left-click to search with chat input. + - Right-click for advanced search options. player-heads: type: ITEM head-id: '' @@ -114,7 +122,7 @@ icons: type: ITEM head-id: '' material: CHEST - name: More Heads + name: Custom Heads lore: - Browse local custom heads. edit: @@ -180,6 +188,13 @@ icons: name: Collection Filters lore: - Filter results by collections. + filter-ids: + type: ITEM + head-id: '' + material: PAPER + name: Head ID Filters + lore: + - Filter results by head IDs. filter-selected: type: ITEM head-id: '' @@ -230,6 +245,174 @@ icons: lore: - Browse your favorite heads. - Right-click heads to favorite or unfavorite them. + collections: + type: ITEM + head-id: '' + material: BOOKSHELF + name: Collections + lore: + - Browse curated head collections. + tags: + type: ITEM + head-id: '' + material: NAME_TAG + name: Tags + lore: + - Browse heads by tag. + create-tag: + type: ITEM + head-id: '' + material: NAME_TAG + name: Create Tag + lore: + - Create a local custom tag. + create-collection: + type: ITEM + head-id: '' + material: BOOKSHELF + name: Create Collection + lore: + - Create a local custom collection. + taxonomy-id: + type: ITEM + head-id: '' + material: PAPER + name: ID + lore: + - Set the local entry id. + taxonomy-name: + type: ITEM + head-id: '' + material: NAME_TAG + name: Name + lore: + - Set the local entry display name. + taxonomy-description: + type: ITEM + head-id: '' + material: WRITABLE_BOOK + name: Description + lore: + - Set the local entry description. + taxonomy-save: + type: ITEM + head-id: '' + material: LIME_CONCRETE + name: Save + lore: + - Save this local entry. + create-category: + type: ITEM + head-id: '' + material: MAP + name: Create Category + lore: + - Create a local custom category. + category-description: + type: ITEM + head-id: '' + material: WRITABLE_BOOK + name: Description + lore: + - Set the category description. + category-permission: + type: ITEM + head-id: '' + material: PAPER + name: Permission + lore: + - Shows the required category permission. + category-save-draft: + type: ITEM + head-id: '' + material: ORANGE_CONCRETE + name: Save Draft + lore: + - Save this category as an admin-only draft. + category-publish: + type: ITEM + head-id: '' + material: LIME_CONCRETE + name: Publish + lore: + - Publish this category for permitted users. + category-delete: + type: ITEM + head-id: '' + material: RED_CONCRETE + name: Delete Category + lore: + - Delete this custom category. + create-head: + type: ITEM + head-id: '' + material: PLAYER_HEAD + name: Create Head + lore: + - Create a local custom head. + create-head-held: + type: ITEM + head-id: '' + material: PLAYER_HEAD + name: Use Held Head + lore: + - Use the texture from your held player head. + create-head-fresh: + type: ITEM + head-id: '' + material: BARRIER + name: Reset + lore: + - Clear the current create draft. + create-head-id: + type: ITEM + head-id: '' + material: NAME_TAG + name: ID + lore: + - Set the custom head id. + create-head-name: + type: ITEM + head-id: '' + material: WRITABLE_BOOK + name: Name + lore: + - Set the custom head name. + create-head-texture: + type: ITEM + head-id: '' + material: PLAYER_HEAD + name: Texture + lore: + - Set the texture hash, URL, or base64 value. + create-head-category: + type: ITEM + head-id: '' + material: CHEST + name: Category + lore: + - Open the category selector. + create-head-tags: + type: ITEM + head-id: '' + material: NAME_TAG + name: Tags + lore: + - Open the tag selector. + create-head-collections: + type: ITEM + head-id: '' + material: BOOKSHELF + name: Collections + lore: + - Open the collection selector. + create-head-save: + type: ITEM + head-id: '' + material: ORANGE_CONCRETE + name: Save Draft + lore: + - Save this custom head as an admin-only draft. more-categories: type: ITEM head-id: '' @@ -420,6 +603,13 @@ icons: - SHOW - HIDE - Click to toggle visibility. + edit-publish: + type: ITEM + head-id: '' + material: LIME_CONCRETE + name: Publish + lore: + - Publish this draft head. edit-delete: type: ITEM head-id: '' @@ -505,24 +695,105 @@ icons: name: Reset lore: - Reset this button to its default name, lore, and material. + taxonomy-filter-all: + type: ITEM + head-id: '' + material: HOPPER + name: Show All + lore: + - Showing remote and custom entries. + - Click to show custom only. + taxonomy-filter-custom: + type: ITEM + head-id: '' + material: HOPPER + name: Show Custom + lore: + - Showing custom entries only. + - Click to show all. + taxonomy-search: + type: ITEM + head-id: '' + material: SPYGLASS + name: Search + lore: + - Search entries by id or name. + edit-price: + type: ITEM + head-id: '' + material: GOLD_INGOT + name: Edit Price + lore: + - Edit the economy price. + category-price: + type: ITEM + head-id: '' + material: GOLD_INGOT + name: Edit Price + lore: + - Edit this category economy price. texts: - category-count: "%name% (%count%)" + category-count: '%name% (%count%)' + title.main: HeadDB + title.browse: Browse + title.collections: Collections + title.collections-page: Collections %page%/%pages% + title.tags: Tags + title.tags-page: Tags %page%/%pages% + title.hidden-heads-page: Hidden Heads %page%/%pages% + title.custom-heads-page: Custom Heads %page%/%pages% + title.player-heads-page: Player Heads %page%/%pages% + title.create-head: Create Head + title.create-category: Create Category + title.create-tag: Create Tag + title.create-collection: Create Collection + title.settings: Settings + title.collections-custom-page: Custom Collections %page%/%pages% + title.tags-custom-page: Custom Tags %page%/%pages% + title.confirm-refresh: Confirm Refresh + title.confirm-reload: Confirm Reload slots: - main.browse-heads: 31 - main.player-heads: 37 - main.custom-heads: 28 - main.favorites: 40 - main.hidden-heads: 51 - main.search: 43 - main.more-categories: 34 + main.player-heads: 11 + main.browse: 13 + main.favorites: 15 + main.search: 31 main.settings: 53 main.info: 49 - settings.admin-mode: 13 - settings.language: 20 - settings.debug: 24 - settings.verify: 33 - settings.reload: 40 - settings.refresh: 42 + browse.hidden-heads: 35 + browse.tags: 2 + browse.all: 4 + browse.collections: 6 + browse.create-category: 18 + browse.custom-heads: 26 + browse.create-head: 27 + browse.search: 53 + create-head.preview: 4 + create-head.use-held: 12 + create-head.reset: 14 + create-head.id: 20 + create-head.name: 22 + create-head.texture: 24 + create-head.category: 29 + create-head.tags: 31 + create-head.collections: 33 + create-head.save: 40 + create-category.name: 20 + create-category.description: 22 + create-category.material: 24 + create-category.head-icon: 29 + create-category.permission: 31 + create-category.heads: 33 + create-category.save-draft: 38 + create-category.publish: 40 + create-category.delete: 42 + create-category.back: 45 + create-head.back: 45 + settings.admin-mode: 31 + settings.language: 13 + settings.debug: 38 + settings.verify: 39 + settings.reload: 42 + settings.refresh: 41 settings.back: 45 edit.preview: 13 edit.name: 21 @@ -533,6 +804,7 @@ slots: edit.visibility: 39 edit.reset: 41 edit.delete: 41 + edit.publish: 39 edit.back: 45 edit.lore.preview: 4 edit.lore.add: 48 @@ -540,7 +812,13 @@ slots: edit.lore.reset: 53 edit.lore.back: 45 hidden.show-all: 53 - + tags.create: 27 + collections.create: 27 + taxonomy.id: 20 + taxonomy.name: 22 + taxonomy.description: 24 + taxonomy.save: 40 + taxonomy.back: 45 more.category-create.name: 20 more.category-create.material: 23 more.category-create.head-icon: 24 @@ -550,7 +828,7 @@ slots: material.back: 45 material.previous: 48 material.type-chat: 49 - material.next: 53 + material.next: 50 gui-button.back: 18 gui-button.preview: 4 gui-button.name: 10 @@ -564,3 +842,10 @@ slots: gui-button-lore.add: 48 gui-button-lore.clear: 50 gui-button-lore.reset: 53 + tags.filter: 52 + tags.search: 53 + collections.filter: 52 + collections.search: 53 + settings.language-only: 4 + edit.price: 35 + create-category.price: 44 diff --git a/headdb-platforms/headdb-paper/src/main/resources/messages/en-US.yml b/headdb-platforms/headdb-paper/src/main/resources/messages/en-US.yml index 342be7d..d60b51a 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/messages/en-US.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/messages/en-US.yml @@ -63,3 +63,15 @@ gui: language: changed: "Language changed to {locale}." reset: "Language reset to the default locale." + price: + prompt: "Enter the new price. Use 0 to clear the custom price." + invalid: "Price must be a valid number." + updated: "Updated price for {target} to {price}." + taxonomy: + created: "Created custom {type}: {name} ({id})." + deleted: "Deleted custom {type}: {id}." + unknown: "Unknown custom {type}: {id}" + confirm: + refresh: "Confirm database refresh." + reload: "Confirm plugin reload." + diff --git a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml index 9d36c81..1373e7d 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml @@ -5,7 +5,7 @@ website: https://github.com/SilentDevelopment/HeadDB main: io.github.silentdevelopment.headdb.paper.HeadDBPlugin version: '${headdb.build.version}' -api-version: '1.21' +api-version: '26.1.2' folia-supported: true loader: io.github.silentdevelopment.headdb.paper.runtime.HeadDBPluginLoader @@ -38,12 +38,21 @@ permissions: headdb.more-heads.give.others: true headdb.more-heads.admin: true headdb.favorites: true + headdb.collections: true + headdb.tags: true headdb.more-categories: true headdb.more-categories.admin: true headdb.settings: true headdb.admin-mode: true headdb.head-edit: true headdb.gui-admin: true + headdb.gui.create-head: true + headdb.gui.create-tag: true + headdb.gui.create-collection: true + headdb.command.tags.create: true + headdb.command.tags.delete: true + headdb.command.collections.create: true + headdb.command.collections.delete: true headdb.database: true headdb.admin.update: true headdb.category.*: true @@ -59,6 +68,8 @@ permissions: headdb.browse: true headdb.search: true headdb.favorites: true + headdb.collections: true + headdb.tags: true headdb.head.take: true headdb.open: @@ -71,19 +82,24 @@ permissions: description: Allows browsing HeadDB categories and GUI head lists. children: headdb.gui.browse: true + headdb.gui.browse.menu: true headdb.gui.category.view: true headdb.gui.category.open: true - headdb.category.*: true + headdb.gui.tags: true headdb.search: description: Allows using HeadDB search command, GUI search, and filters. children: headdb.command.search: true headdb.gui.search: true + headdb.gui.search.advanced: true headdb.gui.filter: true + headdb.gui.filter.ids: true + headdb.gui.filter.categories: true + headdb.gui.filter.tags: true + headdb.gui.filter.collections: true headdb.gui.browse: true headdb.gui.category.open: true - headdb.category.*: true headdb.head.take: description: Allows taking heads from HeadDB GUIs. @@ -136,9 +152,12 @@ permissions: headdb.more-heads.admin: description: Allows creating, deleting, and renaming More Heads. children: + headdb.more-heads: true headdb.command.custom.create: true headdb.command.custom.delete: true headdb.command.custom.rename: true + headdb.gui.create-head: true + headdb.gui.admin-mode: true headdb.favorites: description: Allows opening Favorites and toggling favorite heads. @@ -147,6 +166,18 @@ permissions: headdb.gui.favorites.toggle: true headdb.gui.browse: true + headdb.collections: + description: Allows opening and browsing Collections. + children: + headdb.gui.collections: true + headdb.gui.browse: true + + headdb.tags: + description: Allows opening and browsing Tags. + children: + headdb.gui.tags: true + headdb.gui.browse: true + headdb.more-categories: description: Allows opening More Categories. children: @@ -191,6 +222,12 @@ permissions: children: headdb.gui.button-config: true headdb.gui.hidden-heads: true + headdb.gui.create-tag: true + headdb.gui.create-collection: true + headdb.command.tags.create: true + headdb.command.tags.delete: true + headdb.command.collections.create: true + headdb.command.collections.delete: true headdb.gui.admin-mode: true headdb.database: @@ -237,6 +274,18 @@ permissions: headdb.command.search: description: Allows executing HeadDB searches with /hdb search and GUI search input. + headdb.command.tags.create: + description: Allows creating local custom tags with /hdb tags create. + + headdb.command.tags.delete: + description: Allows deleting local custom tags with /hdb tags delete. + + headdb.command.collections.create: + description: Allows creating local custom collections with /hdb collections create. + + headdb.command.collections.delete: + description: Allows deleting local custom collections with /hdb collections delete. + headdb.command.info: description: Allows using /hdb info. @@ -311,12 +360,30 @@ permissions: headdb.gui.browse: description: Allows browsing head-result GUIs. + headdb.gui.browse.menu: + description: Allows opening the Browse menu. + headdb.gui.search: description: Allows viewing and using the GUI search button. + headdb.gui.search.advanced: + description: Allows opening the advanced search menu. + headdb.gui.filter: description: Allows opening and using GUI sort/filter controls. + headdb.gui.filter.ids: + description: Allows filtering GUI searches by head IDs. + + headdb.gui.filter.categories: + description: Allows filtering GUI searches by categories. + + headdb.gui.filter.tags: + description: Allows filtering GUI searches by tags. + + headdb.gui.filter.collections: + description: Allows filtering GUI searches by collections. + headdb.gui.head.take: description: Allows taking heads from HeadDB GUIs. @@ -338,6 +405,21 @@ permissions: headdb.gui.favorites.toggle: description: Allows adding and removing favorite heads. + headdb.gui.collections: + description: Allows opening the Collections GUI. + + headdb.gui.tags: + description: Allows opening the Tags GUI. + + headdb.gui.create-head: + description: Allows opening the custom head creation GUI in Admin Mode. + + headdb.gui.create-tag: + description: Allows opening the custom tag creation GUI in Admin Mode. + + headdb.gui.create-collection: + description: Allows opening the custom collection creation GUI in Admin Mode. + headdb.gui.more-categories: description: Allows opening the More Categories GUI. From 7c8d1e76ba99c1a10db27cce7e85a32dcdd157fc Mon Sep 17 00:00:00 2001 From: TheSilentPro <46107752+TheSilentPro@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:12:55 +0200 Subject: [PATCH 2/4] feat: add interaction sounds and optimize GUI rendering --- .../headdb/paper/HeadDBPlugin.java | 66 +++- .../command/CommandResponseRenderer.java | 15 + .../command/subcommand/DebugCommand.java | 5 + .../paper/command/subcommand/GiveCommand.java | 20 + .../command/subcommand/PlayerCommand.java | 19 + .../command/subcommand/RandomCommand.java | 19 + .../command/subcommand/RefreshCommand.java | 4 + .../command/subcommand/ReloadCommand.java | 4 + .../command/subcommand/VerifyCommand.java | 7 + .../headdb/paper/economy/EconomyService.java | 3 + .../headdb/paper/gui/GuiService.java | 14 + .../headdb/paper/gui/category/BrowseMenu.java | 4 + .../gui/category/CategoryHeadPickerMenu.java | 3 + .../gui/category/CategoryMembersMenu.java | 3 + .../paper/gui/category/CollectionsMenu.java | 4 + .../gui/category/CreateCategoryMenu.java | 16 +- .../paper/gui/category/CreateHeadMenu.java | 12 + .../gui/category/CreateTaxonomyMenu.java | 12 +- .../gui/category/CustomCategoryService.java | 74 ++-- .../gui/category/CustomCategoryViewMenu.java | 3 + .../category/DeleteCategoryConfirmMenu.java | 5 + .../gui/category/MoreCategoriesMenu.java | 11 +- .../headdb/paper/gui/category/TagsMenu.java | 4 + .../headdb/paper/gui/common/GuiHeadIcons.java | 22 ++ .../paper/gui/config/GuiButtonEditorMenu.java | 3 + .../gui/config/GuiButtonLoreEditorMenu.java | 3 + .../paper/gui/edit/DeleteHeadConfirmMenu.java | 6 + .../paper/gui/edit/HeadEditListener.java | 23 ++ .../headdb/paper/gui/edit/HeadEditMenu.java | 4 + .../gui/favorites/FavoriteHeadService.java | 79 ++-- .../paper/gui/favorites/FavoritesMenu.java | 6 +- .../paper/gui/hidden/HiddenHeadsMenu.java | 8 +- .../paper/gui/local/LocalHeadListMenu.java | 6 +- .../gui/material/MaterialSelectionMenu.java | 3 + .../gui/search/SearchFilterPageFactory.java | 63 +++- .../paper/gui/search/SearchPageFactory.java | 6 + .../gui/settings/CommandConfirmMenu.java | 23 ++ .../paper/item/CachingHeadItemFactory.java | 38 +- .../headdb/paper/local/HeadRegistry.java | 356 +++++++++++++----- .../local/custom/CachedCustomHeadStore.java | 79 ++++ .../CachedRemoteHeadOverrideStore.java | 97 +++++ .../local/taxonomy/CustomTaxonomyService.java | 58 +-- .../headdb/paper/runtime/PluginRuntime.java | 4 + .../paper/search/SearchResultCache.java | 39 +- .../headdb/paper/sound/HeadSoundService.java | 123 ++++++ .../headdb/paper/sound/SoundConfig.java | 121 ++++++ .../headdb/paper/sound/SoundConfigLoader.java | 183 +++++++++ .../headdb/paper/sound/SoundEntry.java | 30 ++ .../headdb/paper/sound/SoundKey.java | 312 +++++++++++++++ .../src/main/resources/sounds.yml | 250 ++++++++++++ .../headdb/paper/sound/SoundConfigTest.java | 33 ++ 51 files changed, 2064 insertions(+), 241 deletions(-) create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CachedCustomHeadStore.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/override/CachedRemoteHeadOverrideStore.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/HeadSoundService.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfig.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigLoader.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundEntry.java create mode 100644 headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java create mode 100644 headdb-platforms/headdb-paper/src/main/resources/sounds.yml create mode 100644 headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigTest.java diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java index 19befbd..39f8430 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java @@ -18,7 +18,9 @@ import io.github.silentdevelopment.headdb.paper.item.DefaultHeadItemFactory; import io.github.silentdevelopment.headdb.paper.item.HeadItemFactory; import io.github.silentdevelopment.headdb.paper.local.HeadRegistry; +import io.github.silentdevelopment.headdb.paper.local.custom.CachedCustomHeadStore; import io.github.silentdevelopment.headdb.paper.local.custom.CustomHeadStore; +import io.github.silentdevelopment.headdb.paper.local.override.CachedRemoteHeadOverrideStore; import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverrideStore; import io.github.silentdevelopment.headdb.paper.local.player.BukkitPlayerHeadService; import io.github.silentdevelopment.headdb.paper.local.player.DisabledPlayerHeadService; @@ -36,6 +38,9 @@ import io.github.silentdevelopment.headdb.paper.runtime.RuntimeDiagnostics; import io.github.silentdevelopment.headdb.paper.runtime.StartupChecks; import io.github.silentdevelopment.headdb.paper.service.PaperHeadDBService; +import io.github.silentdevelopment.headdb.paper.sound.HeadSoundService; +import io.github.silentdevelopment.headdb.paper.sound.SoundConfig; +import io.github.silentdevelopment.headdb.paper.sound.SoundConfigLoader; import io.github.silentdevelopment.headdb.paper.updater.UpdateService; import io.github.silentdevelopment.hermes.id.LocaleId; import io.github.silentdevelopment.hermes.paper.core.Hermes; @@ -64,6 +69,7 @@ public final class HeadDBPlugin extends JavaPlugin { private CustomTaxonomyService customTagService; private CustomTaxonomyService customCollectionService; private EconomyService economyService; + private HeadSoundService soundService; private UpdateService updateService; @Override @@ -123,6 +129,7 @@ public void onDisable() { customTagService = null; customCollectionService = null; economyService = null; + soundService = null; updateService = null; runtime = null; config = null; @@ -141,19 +148,21 @@ public synchronized void reload() throws ConfigException { PluginConfig loadedConfig = new ConfigLoader(getDataFolder().toPath()).load(); GuiConfig loadedGuiConfig = new GuiConfigLoader(this).load(); EconomyConfig loadedEconomyConfig = EconomyConfig.load(this); + SoundConfig loadedSoundConfig = new SoundConfigLoader(this).load(); Messages loadedMessages = createMessages(loadedConfig); PluginRuntime createdRuntime = PluginRuntime.create(this, loadedConfig); Path localStoreDatabase = loadedConfig.localStoreDatabase(getDataFolder().toPath()); SqliteStorageMigrator.migrate(localStoreDatabase); - HeadRegistry createdHeadRegistry = createHeadRegistry(loadedConfig, createdRuntime); - HeadItemFactory createdItemFactory = createItemFactory(loadedConfig); - FavoriteHeadService createdFavoriteHeadService = new FavoriteHeadService(localStoreDatabase); - CustomCategoryService createdCustomCategoryService = new CustomCategoryService(localStoreDatabase); CustomTaxonomyService createdCustomTagService = new CustomTaxonomyService(localStoreDatabase, "tag", "Local custom tag."); CustomTaxonomyService createdCustomCollectionService = new CustomTaxonomyService(localStoreDatabase, "collection", "Local custom collection."); + HeadRegistry createdHeadRegistry = createHeadRegistry(loadedConfig, createdRuntime, createdCustomTagService, createdCustomCollectionService); + HeadItemFactory createdItemFactory = createItemFactory(loadedConfig, createdHeadRegistry); + FavoriteHeadService createdFavoriteHeadService = new FavoriteHeadService(localStoreDatabase); + CustomCategoryService createdCustomCategoryService = new CustomCategoryService(localStoreDatabase); EconomyService createdEconomyService = EconomyService.create(this, loadedEconomyConfig); + HeadSoundService createdSoundService = new HeadSoundService(this, loadedSoundConfig); GuiService createdGuiService = new GuiService(this, createdItemFactory); UpdateService createdUpdateService = new UpdateService(this, loadedConfig); @@ -163,8 +172,6 @@ public synchronized void reload() throws ConfigException { StartupChecks.run(this, loadedConfig, createdRuntime); } - createdRuntime.start(); - PluginRuntime previousRuntime = this.runtime; UpdateService previousUpdateService = this.updateService; @@ -180,6 +187,7 @@ public synchronized void reload() throws ConfigException { this.customTagService = createdCustomTagService; this.customCollectionService = createdCustomCollectionService; this.economyService = createdEconomyService; + this.soundService = createdSoundService; this.updateService = createdUpdateService; clearItemCache(); @@ -193,6 +201,8 @@ public synchronized void reload() throws ConfigException { previousRuntime.close(); } + createdRuntime.start(); + RuntimeDiagnostics.logRuntimeState(this, createdRuntime); registerServices(); @@ -342,6 +352,14 @@ public synchronized void reloadGuiConfigOnly() throws ConfigException { return currentEconomyService; } + public @NotNull HeadSoundService sounds() { + HeadSoundService currentSoundService = soundService; + if (currentSoundService == null) { + throw new IllegalStateException("HeadDB sound service is not initialized"); + } + return currentSoundService; + } + public @NotNull UpdateService updater() { UpdateService currentUpdateService = updateService; if (currentUpdateService == null) { @@ -380,6 +398,27 @@ public int itemCacheSize() { return cache.size(); } + + public void invalidateHeadRegistry() { + HeadRegistry currentHeadRegistry = headRegistry; + + if (currentHeadRegistry == null) { + return; + } + + currentHeadRegistry.invalidate(); + } + + public void warmHeadRegistry() { + HeadRegistry currentHeadRegistry = headRegistry; + + if (currentHeadRegistry == null) { + return; + } + + currentHeadRegistry.warm(); + } + public void clearSearchCache() { GuiService currentGuiService = guiService; @@ -401,33 +440,34 @@ private void unregisterServices() { getServer().getServicesManager().unregisterAll(this); } - private @NotNull HeadRegistry createHeadRegistry(@NotNull PluginConfig config, @NotNull PluginRuntime runtime) { + private @NotNull HeadRegistry createHeadRegistry(@NotNull PluginConfig config, @NotNull PluginRuntime runtime, @NotNull CustomTaxonomyService customTags, @NotNull CustomTaxonomyService customCollections) { Objects.requireNonNull(config, "config"); Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(customTags, "customTags"); + Objects.requireNonNull(customCollections, "customCollections"); Path localStoreDatabase = config.localStoreDatabase(getDataFolder().toPath()); StrataLocalStores localStores = StrataLocalStores.sqlite(localStoreDatabase, runnable -> getServer().getAsyncScheduler().runNow(this, task -> runnable.run())); - RemoteHeadOverrideStore overrideStore = config.remoteOverridesEnabled() ? localStores.remoteOverrides() : NoopLocalStores.remoteOverrides(); - CustomHeadStore customHeadStore = config.customHeadsEnabled() ? localStores.customHeads() : NoopLocalStores.customHeads(); + RemoteHeadOverrideStore overrideStore = config.remoteOverridesEnabled() ? new CachedRemoteHeadOverrideStore(localStores.remoteOverrides()) : NoopLocalStores.remoteOverrides(); + CustomHeadStore customHeadStore = config.customHeadsEnabled() ? new CachedCustomHeadStore(localStores.customHeads()) : NoopLocalStores.customHeads(); PlayerHeadCache playerHeadCache = config.playerHeadsEnabled() ? localStores.playerHeadCache(config.playerHeadCacheTtl()) : NoopLocalStores.playerHeadCache(); PlayerHeadService playerHeadService = config.playerHeadsEnabled() ? new BukkitPlayerHeadService(this, playerHeadCache, config.playerHeadCacheTtl(), config.playerHeadFailedCacheTtl(), config.playerHeadsAllowExternalLookup()) : new DisabledPlayerHeadService(); - CustomTaxonomyService customTags = new CustomTaxonomyService(localStoreDatabase, "tag", "Local custom tag."); - CustomTaxonomyService customCollections = new CustomTaxonomyService(localStoreDatabase, "collection", "Local custom collection."); return new HeadRegistry(runtime.database(), overrideStore, customHeadStore, playerHeadService, customTags, customCollections); } - private @NotNull HeadItemFactory createItemFactory(@NotNull PluginConfig config) { + private @NotNull HeadItemFactory createItemFactory(@NotNull PluginConfig config, @NotNull HeadRegistry registry) { Objects.requireNonNull(config, "config"); + Objects.requireNonNull(registry, "registry"); HeadItemFactory baseFactory = new DefaultHeadItemFactory(this); if (!config.cacheItemEnabled()) { return baseFactory; } - return new CachingHeadItemFactory(baseFactory, config.cacheItemMaxSize()); + return new CachingHeadItemFactory(baseFactory, config.cacheItemMaxSize(), registry::revision); } private @NotNull Messages createMessages(@NotNull PluginConfig config) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java index 5654686..2599434 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java @@ -1,6 +1,7 @@ package io.github.silentdevelopment.headdb.paper.command; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.command.Signature; import io.github.silentdevelopment.relay.paper.text.PaperCommandResponseRenderer; @@ -29,26 +30,31 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) { @Override public @NotNull Component renderUnknownCommand(@NotNull CommandSender source) { + play(source, SoundKey.INVALID); return plugin.messages().relayUnknownCommand(source); } @Override public @NotNull Component renderNoHandler(@NotNull CommandSender source) { + play(source, SoundKey.INVALID); return plugin.messages().relayNoHandler(source); } @Override public @NotNull Component renderRequirementFailure(@NotNull CommandSender source, @NotNull CommandText message) { + play(source, SoundKey.NO_PERMISSION); return plugin.messages().render(source, message); } @Override public @NotNull Component renderAbort(@NotNull CommandSender source, @NotNull CommandText message) { + play(source, SoundKey.INVALID); return plugin.messages().render(source, message); } @Override public @NotNull Component renderInvalidUsage(@NotNull CommandSender source, @NotNull CommandText message, @NotNull List usages) { + play(source, SoundKey.INVALID); Component component = plugin.messages().relayInvalidUsage(source, message); if (usages.isEmpty()) { @@ -68,6 +74,7 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) { @Override public @NotNull Component renderInvalidUsage(@NotNull CommandSender source, @NotNull CommandText message, @NotNull String path, @NotNull Command command) { + play(source, SoundKey.INVALID); Component component = plugin.messages().relayInvalidUsage(source, message); if (command.signatures().isEmpty()) { @@ -85,6 +92,14 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) { return component; } + private void play(@NotNull CommandSender source, @NotNull SoundKey key) { + if (!(source instanceof Player player)) { + return; + } + + plugin.sounds().play(player, key); + } + private static @NotNull Component usage(@NotNull CommandSender source, @NotNull String usage) { Component component = Component.text(usage, NamedTextColor.GREEN); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java index 2c7fa76..91167bb 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java @@ -8,6 +8,7 @@ import io.github.silentdevelopment.headdb.paper.runtime.BuildInfo; import io.github.silentdevelopment.headdb.paper.runtime.PlatformRequirements; import io.github.silentdevelopment.headdb.paper.runtime.RefreshState; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.headdb.paper.updater.UpdateCheckResult; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; @@ -35,6 +36,10 @@ public DebugCommand(@NotNull HeadDBPlugin plugin) { @Override protected void handle(@NotNull PaperCommandContext context) { + if (context.isPlayer()) { + plugin.sounds().play(context.player(), SoundKey.DEBUG); + } + BuildInfo buildInfo = BuildInfo.read(plugin); DatabaseStatus status = plugin.runtime().database().status(); DatabaseStats remoteStats = plugin.runtime().database().stats(); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java index 399bf8d..7fc2457 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java @@ -7,6 +7,7 @@ import io.github.silentdevelopment.headdb.paper.command.Suggestions; import io.github.silentdevelopment.headdb.paper.command.search.SearchParser; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.argument.Argument; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes; @@ -43,6 +44,7 @@ protected void handle(@NotNull PaperCommandContext context) { request = request(context); } catch (IllegalArgumentException exception) { context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage())); + play(context, SoundKey.INVALID); return; } @@ -152,17 +154,20 @@ protected void handle(@NotNull PaperCommandContext context) { if (normalizedTargetName.isEmpty()) { context.reply(plugin.messages().targetEmpty(context.sender())); + play(context, SoundKey.INVALID); return null; } Player target = Bukkit.getPlayerExact(normalizedTargetName); if (target == null) { context.reply(plugin.messages().playerNotOnline(context.sender(), normalizedTargetName)); + play(context, SoundKey.INVALID); return null; } if (!Permissions.canGiveTo(context.sender(), target)) { context.reply(plugin.messages().noGiveOthers(context.sender())); + play(context, SoundKey.NO_PERMISSION); return null; } @@ -181,28 +186,43 @@ private void give(@NotNull PaperCommandContext context, @NotNull Player target, item = plugin.itemFactory().create(head); } catch (IllegalArgumentException exception) { context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage())); + play(context, SoundKey.INVALID); return; } if (target.getInventory().firstEmpty() == -1) { context.reply(plugin.messages().giveInventoryFull(context.sender(), target)); + play(context, SoundKey.INVALID); return; } Map remaining = target.getInventory().addItem(item); if (!remaining.isEmpty()) { context.reply(plugin.messages().giveInventoryFull(context.sender(), target)); + play(context, SoundKey.INVALID); return; } } context.reply(plugin.messages().giveSuccess(context.sender(), head, target)); + plugin.sounds().play(target, SoundKey.GIVE_HEAD); + if (context.isPlayer() && !context.player().equals(target)) { + plugin.sounds().play(context.player(), SoundKey.GIVE_HEAD); + } if (!context.sender().equals(target)) { target.sendMessage(plugin.messages().giveReceived(target, head)); } } + private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) { + if (!context.isPlayer()) { + return; + } + + plugin.sounds().play(context.player(), key); + } + private static @Nullable HeadId parseOptionalHeadId(@NotNull String raw) { try { return SearchParser.headId(raw); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java index ef21886..6bd7f0b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java @@ -5,6 +5,7 @@ import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; import io.github.silentdevelopment.headdb.paper.command.Suggestions; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.argument.Argument; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes; @@ -44,6 +45,7 @@ protected void handle(@NotNull PaperCommandContext context) { parsedTarget = parseTarget(context); } catch (IllegalArgumentException exception) { context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage())); + play(context, SoundKey.INVALID); return; } @@ -54,6 +56,7 @@ protected void handle(@NotNull PaperCommandContext context) { if (!Permissions.canPlayerHeadFor(context.sender(), target)) { context.reply(plugin.messages().render(context.sender(), io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + play(context, SoundKey.NO_PERMISSION); return; } @@ -61,6 +64,7 @@ protected void handle(@NotNull PaperCommandContext context) { plugin.headRegistry().playerHeads().resolve(lookup).whenComplete((head, throwable) -> plugin.getServer().getGlobalRegionScheduler().execute(plugin, () -> { if (throwable != null) { context.reply(Component.text("Could not resolve player head: ", NamedTextColor.RED).append(Component.text(message(throwable), NamedTextColor.GRAY))); + play(context, SoundKey.INVALID); return; } @@ -71,11 +75,16 @@ protected void handle(@NotNull PaperCommandContext context) { for (int index = 0; index < parsedTarget.amount(); index++) { if (!give(target, head)) { context.reply(plugin.messages().giveInventoryFull(context.sender(), target)); + play(context, SoundKey.INVALID); return; } } context.reply(plugin.messages().giveSuccess(context.sender(), head, target)); + plugin.sounds().play(target, SoundKey.PLAYER_HEAD); + if (context.isPlayer() && !context.player().equals(target)) { + plugin.sounds().play(context.player(), SoundKey.PLAYER_HEAD); + } })); } @@ -119,12 +128,14 @@ protected void handle(@NotNull PaperCommandContext context) { return context.player(); } context.reply(Component.text("Usage: /hdb player [amount]", NamedTextColor.RED)); + play(context, SoundKey.INVALID); return null; } Player target = Bukkit.getPlayerExact(targetName.trim()); if (target == null) { context.reply(plugin.messages().playerNotOnline(context.sender(), targetName)); + play(context, SoundKey.INVALID); return null; } return target; @@ -139,6 +150,14 @@ private boolean give(@NotNull Player target, @NotNull Head head) { return remaining.isEmpty(); } + private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) { + if (!context.isPlayer()) { + return; + } + + plugin.sounds().play(context.player(), key); + } + private static boolean isAmount(@NotNull String raw) { try { parseAmount(raw); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java index d8d7bfc..9631519 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java @@ -7,6 +7,7 @@ import io.github.silentdevelopment.headdb.paper.item.HeadItemFactory; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.headdb.query.HeadQuery; import io.github.silentdevelopment.headdb.query.HeadQueryResult; import io.github.silentdevelopment.headdb.query.HeadSort; @@ -50,6 +51,7 @@ protected void handle(@NotNull PaperCommandContext context) { request = request(context); } catch (IllegalArgumentException exception) { context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage())); + play(context, SoundKey.INVALID); return; } @@ -60,6 +62,7 @@ protected void handle(@NotNull PaperCommandContext context) { if (!Permissions.canGiveTo(context.sender(), target)) { context.reply(plugin.messages().render(context.sender(), MessageKey.COMMAND_ERROR_NO_PERMISSION)); + play(context, SoundKey.NO_PERMISSION); return; } @@ -67,6 +70,7 @@ protected void handle(@NotNull PaperCommandContext context) { Optional optionalHead = randomHead(request.category()); if (optionalHead.isEmpty()) { context.reply(plugin.messages().randomEmpty(context.sender())); + play(context, SoundKey.INVALID); return; } @@ -76,10 +80,15 @@ protected void handle(@NotNull PaperCommandContext context) { if (!give(target, optionalHead.get())) { context.reply(plugin.messages().giveInventoryFull(context.sender(), target)); + play(context, SoundKey.INVALID); return; } context.reply(plugin.messages().giveSuccess(context.sender(), optionalHead.get(), target)); + plugin.sounds().play(target, SoundKey.GIVE_HEAD); + if (context.isPlayer() && !context.player().equals(target)) { + plugin.sounds().play(context.player(), SoundKey.GIVE_HEAD); + } } } @@ -138,6 +147,7 @@ private boolean give(@NotNull Player target, @NotNull Head head) { } context.reply(plugin.messages().randomConsoleUsage(context.sender())); + play(context, SoundKey.INVALID); return null; } @@ -147,6 +157,7 @@ private boolean give(@NotNull Player target, @NotNull Head head) { } context.reply(plugin.messages().playerNotOnline(context.sender(), playerName)); + play(context, SoundKey.INVALID); return null; } @@ -186,6 +197,14 @@ private void consume(@NotNull PaperCommandContext context, @NotNull RandomReques throw new IllegalArgumentException("Could not parse random command argument: " + value); } + private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) { + if (!context.isPlayer()) { + return; + } + + plugin.sounds().play(context.player(), key); + } + private static boolean isAmount(@NotNull String value) { try { Integer.parseInt(value); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java index 867081b..26c80d9 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java @@ -3,6 +3,7 @@ import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; import io.github.silentdevelopment.relay.paper.command.PaperCommands; @@ -29,6 +30,9 @@ protected void handle(@NotNull PaperCommandContext context) { } context.reply(plugin.messages().refreshStarted(context.sender())); + if (context.isPlayer()) { + plugin.sounds().play(context.player(), SoundKey.REFRESH); + } } @Override diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java index fad89ed..09fa05d 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java @@ -3,6 +3,7 @@ import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; import io.github.silentdevelopment.relay.paper.command.PaperCommands; @@ -33,6 +34,9 @@ protected void handle(@NotNull PaperCommandContext context) { } context.reply(plugin.messages().reloadSuccess(context.sender())); + if (context.isPlayer()) { + plugin.sounds().play(context.player(), SoundKey.RELOAD); + } } @Override diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java index 6977a14..d9cf33a 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java @@ -6,6 +6,7 @@ import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.relay.command.Command; import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; import io.github.silentdevelopment.relay.paper.command.PaperCommands; @@ -29,6 +30,9 @@ public VerifyCommand(@NotNull HeadDBPlugin plugin) { protected void handle(@NotNull PaperCommandContext context) { if (!Permissions.has(context.sender(), Permissions.VERIFY)) { context.reply(plugin.messages().render(context.sender(), MessageKey.COMMAND_ERROR_NO_PERMISSION)); + if (context.isPlayer()) { + plugin.sounds().play(context.player(), SoundKey.NO_PERMISSION); + } return; } @@ -45,6 +49,9 @@ protected void handle(@NotNull PaperCommandContext context) { } context.reply(plugin.messages().verifyStarted(context.sender())); + if (context.isPlayer()) { + plugin.sounds().play(context.player(), SoundKey.VERIFY); + } } @Override diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java index 6d268ca..043e7fb 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java @@ -6,6 +6,7 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.RegisteredServiceProvider; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -112,10 +113,12 @@ public boolean charge(@NotNull Player player, @NotNull Head head, int amount, @N if (withdrawal.success()) { player.sendMessage(plugin.messages().economyPurchased(player, head, formattedPrice)); + plugin.sounds().play(player, SoundKey.ECONOMY_PURCHASE); return true; } player.sendMessage(plugin.messages().economyInvalidFunds(player, head, formattedPrice)); + plugin.sounds().play(player, SoundKey.ECONOMY_INSUFFICIENT_FUNDS); return false; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java index ce65602..93a537b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java @@ -18,6 +18,7 @@ import io.github.silentdevelopment.headdb.paper.permission.Permissions; import io.github.silentdevelopment.headdb.paper.search.SearchRequest; import io.github.silentdevelopment.headdb.paper.search.SearchResultCache; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; @@ -48,10 +49,12 @@ public void openMain(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_MAIN)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } grafik.open(mainGui, new MenuState(player.getUniqueId()), player); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public void openBrowse(@NotNull Player player) { @@ -59,6 +62,7 @@ public void openBrowse(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -76,10 +80,12 @@ public void openAdvancedSearch(@NotNull Player player, @NotNull SearchRequest re if (!Permissions.has(player, Permissions.GUI_SEARCH_ADVANCED) || !Permissions.has(player, Permissions.GUI_FILTER)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } grafik.open(advancedSearchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, backTarget), player); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public void openCollections(@NotNull Player player) { @@ -87,6 +93,7 @@ public void openCollections(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -98,6 +105,7 @@ public void openTags(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_TAGS)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -115,10 +123,12 @@ public void openSearch(@NotNull Player player, @NotNull SearchRequest request, @ if (!canOpenSearch(player, request)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } grafik.open(searchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, SearchMenuState.BackTarget.RESULTS), player); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } private boolean canOpenSearch(@NotNull Player player, @NotNull SearchRequest request) { @@ -150,6 +160,7 @@ public void openPlayerHeads(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_PLAYER_HEADS)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -161,6 +172,7 @@ public void openFavorites(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_FAVORITES)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -172,6 +184,7 @@ public void openMoreCategories(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_MORE_CATEGORIES)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -183,6 +196,7 @@ public void openCustomHeads(@NotNull Player player) { if (!Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java index a823e95..51bb7d8 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java @@ -99,6 +99,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, i renderCategories(plugin, player, inventory, categories, page); renderControls(plugin, player, inventory, categories.size(), page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -125,6 +126,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get(), event.getClick()); return true; } @@ -561,6 +564,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); } private static @NotNull SearchRequest browseRequest() { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java index 39f064d..a70fad9 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java @@ -78,6 +78,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N renderHeads(plugin, inventory, heads, page); renderControls(plugin, inventory, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -104,6 +105,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get(), item); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java index d325017..998b7c6 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java @@ -72,6 +72,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N renderMembers(plugin, inventory, ids, page); renderControls(plugin, inventory, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -98,6 +99,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java index ef04ea5..d940896 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java @@ -99,6 +99,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, i renderEntries(plugin, player, inventory, entries, counts, customIds, page); renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -125,6 +126,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get()); return true; } @@ -354,6 +357,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); } private static final class Holder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java index 2837cb4..e190c64 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java @@ -8,6 +8,7 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -99,6 +100,7 @@ public static void openExisting(@NotNull HeadDBPlugin plugin, @NotNull Player pl fillBorder(plugin, inventory); render(plugin, inventory, existing.get()); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -117,7 +119,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } Optional action = readAction(plugin, item); - action.ifPresent(value -> handleAction(plugin, player, holder, value)); + action.ifPresent(value -> { + if (!value.equals(ACTION_SAVE_DRAFT) && !value.equals(ACTION_PUBLISH)) { + plugin.sounds().playGuiAction(player, value); + } + handleAction(plugin, player, holder, value); + }); return true; } @@ -179,6 +186,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p if (action.equals(ACTION_SAVE_DRAFT)) { plugin.customCategories().save(category.withDraft(true)); player.sendMessage(Component.text("Category draft saved: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD))); + plugin.sounds().play(player, SoundKey.SAVE_DRAFT); openExisting(plugin, player, category.id()); return; } @@ -188,6 +196,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p ACTIVE_DRAFTS.remove(player.getUniqueId()); plugin.clearSearchCache(); player.sendMessage(Component.text("Category published: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD))); + plugin.sounds().play(player, SoundKey.PUBLISH); openExisting(plugin, player, category.id()); return; } @@ -226,6 +235,7 @@ private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, openExisting(plugin, player, updated.id()); } catch (IllegalArgumentException exception) { player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.VALIDATION_ERROR); openExisting(plugin, player, category.id()); } }, () -> openExisting(plugin, player, category.id())); @@ -298,10 +308,13 @@ private static void promptPrice(@NotNull HeadDBPlugin plugin, @NotNull Player pl double price = Double.parseDouble(value.trim()); plugin.economy().setCustomCategoryPrice(category.id(), price); player.sendMessage(plugin.messages().priceUpdated(player, categoryLabel(plugin, player, category), plugin.economy().format(price))); + plugin.sounds().play(player, SoundKey.PRICE_SUCCESS); } catch (NumberFormatException exception) { player.sendMessage(plugin.messages().priceInvalid(player)); + plugin.sounds().play(player, SoundKey.PRICE_FAILURE); } catch (RuntimeException exception) { player.sendMessage(Component.text("Failed to update category price: " + exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.PRICE_FAILURE); } openExisting(plugin, player, category.id()); @@ -353,6 +366,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); } private static final class Holder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java index 98ed515..0ea0879 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java @@ -16,6 +16,7 @@ import io.github.silentdevelopment.headdb.paper.local.texture.TextureInputParser; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -112,6 +113,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) { fillBorder(plugin, inventory); render(plugin, inventory, draft); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -139,6 +141,10 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + if (!action.get().equals(ACTION_CREATE)) { + plugin.sounds().playGuiAction(player, action.get()); + } + if (holder instanceof CreateHeadSelectorHolder selectorHolder) { handleSelectorAction(plugin, player, selectorHolder, action.get()); return true; @@ -184,6 +190,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p update(player, draft.withTexture(texture.hash())); } catch (IllegalArgumentException exception) { player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.VALIDATION_ERROR); } }); return; @@ -232,6 +239,7 @@ private static void openSelector(@NotNull HeadDBPlugin plugin, @NotNull Player p renderSelectorEntries(plugin, inventory, draft, mode, entries, page); renderSelectorControls(plugin, inventory, draft, mode, entries.size(), page, pages); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } private static void renderSelectorEntries(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull SelectorMode mode, @NotNull List entries, int page) { @@ -482,6 +490,7 @@ private static void useHeld(@NotNull HeadDBPlugin plugin, @NotNull Player player player.getScheduler().run(plugin, task -> open(plugin, player), () -> {}); } catch (IllegalArgumentException exception) { player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.INVALID); } } @@ -519,6 +528,7 @@ private static void saveDraft(@NotNull HeadDBPlugin plugin, @NotNull Player play DRAFTS.remove(player.getUniqueId()); player.sendMessage(Component.text("Saved draft head: ", NamedTextColor.GRAY).append(Component.text(head.name() + " (" + head.headId().display() + ")", NamedTextColor.GOLD))); + plugin.sounds().play(player, SoundKey.SAVE_DRAFT); HeadEditMenu.open(plugin, player, HeadId.custom(head.id())); } catch (IllegalArgumentException exception) { if (!reopenEdit) { @@ -526,6 +536,7 @@ private static void saveDraft(@NotNull HeadDBPlugin plugin, @NotNull Player play } player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.VALIDATION_ERROR); open(plugin, player); } } @@ -592,6 +603,7 @@ private static int pageCount(int entries, int pageSize) { private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); } private record SelectionEntry(@NotNull String id, @NotNull String name) {} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java index 3c6d9a0..91fc401 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java @@ -8,6 +8,7 @@ import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -80,6 +81,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ fillBorder(plugin, inventory); render(plugin, inventory, draft, mode); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -102,7 +104,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } Optional action = readAction(plugin, item); - action.ifPresent(value -> handleAction(plugin, player, holder.mode(), value)); + action.ifPresent(value -> { + if (!value.equals(ACTION_SAVE)) { + plugin.sounds().playGuiAction(player, value); + } + handleAction(plugin, player, holder.mode(), value); + }); return true; } @@ -159,9 +166,11 @@ private static void save(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ plugin.headRegistry().onLocalMutation(); plugin.clearSearchCache(); player.sendMessage(plugin.messages().taxonomyCreated(player, mode.displayName().toLowerCase(java.util.Locale.ROOT), entry.name(), entry.id())); + plugin.sounds().play(player, mode == Mode.TAG ? SoundKey.TAG_CREATE : SoundKey.COLLECTION_CREATE); back(plugin, player, mode); } catch (IllegalArgumentException exception) { player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.VALIDATION_ERROR); open(plugin, player, mode); } } @@ -245,6 +254,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); } private enum Mode { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java index e783d4d..482daab 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java @@ -22,6 +22,7 @@ public final class CustomCategoryService { private final SQLiteDataSource dataSource; + private List cache; public CustomCategoryService(@NotNull Path databaseFile) { Objects.requireNonNull(databaseFile, "databaseFile"); @@ -30,20 +31,13 @@ public CustomCategoryService(@NotNull Path databaseFile) { } public synchronized @NotNull List list() { - List categories = new ArrayList<>(); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories ORDER BY lower(name) ASC")) { - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - String id = result.getString("id"); - categories.add(categoryFromRow(result, headIds(connection, id))); - } - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to list HeadDB custom categories.", exception); + List current = cache; + if (current != null) { + return current; } - - categories.sort(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER)); - return List.copyOf(categories); + current = loadAll(); + cache = current; + return current; } @@ -67,18 +61,12 @@ public CustomCategoryService(@NotNull Path databaseFile) { public synchronized @NotNull String nextId() { int next = 1; - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id FROM headdb_custom_categories")) { - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - try { - next = Math.max(next, Integer.parseInt(result.getString("id")) + 1); - } catch (NumberFormatException ignored) { - // Non-numeric legacy IDs are ignored for automatic assignment. - } - } + for (CustomCategory category : list()) { + try { + next = Math.max(next, Integer.parseInt(category.id()) + 1); + } catch (NumberFormatException ignored) { + // Non-numeric legacy IDs are ignored for automatic assignment. } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to calculate next HeadDB custom category id.", exception); } while (find(String.valueOf(next)).isPresent()) { @@ -91,19 +79,7 @@ public CustomCategoryService(@NotNull Path databaseFile) { public synchronized @NotNull Optional find(@NotNull String id) { Objects.requireNonNull(id, "id"); String normalized = normalize(id); - - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories WHERE id = ?")) { - statement.setString(1, normalized); - try (ResultSet result = statement.executeQuery()) { - if (!result.next()) { - return Optional.empty(); - } - - return Optional.of(categoryFromRow(result, headIds(connection, normalized))); - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to read HeadDB custom category " + normalized + ".", exception); - } + return list().stream().filter(category -> category.id().equals(normalized)).findFirst(); } public synchronized void save(@NotNull CustomCategory category) { @@ -135,6 +111,7 @@ public synchronized void save(@NotNull CustomCategory category) { } connection.commit(); + cache = null; } catch (SQLException exception) { throw new IllegalStateException("Failed to save HeadDB custom category " + category.id() + ".", exception); } @@ -172,12 +149,33 @@ public synchronized boolean delete(@NotNull String id) { updated = category.executeUpdate(); } connection.commit(); - return updated > 0; + if (updated > 0) { + cache = null; + return true; + } + return false; } catch (SQLException exception) { throw new IllegalStateException("Failed to delete HeadDB custom category " + normalized + ".", exception); } } + private @NotNull List loadAll() { + List categories = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories ORDER BY lower(name) ASC")) { + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + String id = result.getString("id"); + categories.add(categoryFromRow(result, headIds(connection, id))); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Failed to list HeadDB custom categories.", exception); + } + + categories.sort(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(categories); + } + private @NotNull Set headIds(@NotNull Connection connection, @NotNull String categoryId) throws SQLException { LinkedHashSet ids = new LinkedHashSet<>(); try (PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_custom_category_heads WHERE category_id = ? ORDER BY rowid ASC")) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java index b96248a..6127db6 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java @@ -88,6 +88,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N renderHeads(plugin, player, inventory, heads, page, adminMode); renderControls(plugin, player, inventory, category, heads.size(), page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event, @NotNull Consumer edit, @NotNull Consumer give) { @@ -107,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player Optional action = readAction(plugin, item); if (action.isPresent()) { + plugin.sounds().playGuiAction(player, action.get()); handleAction(plugin, player, holder, action.get()); return true; } @@ -255,6 +257,7 @@ private static int pageCount(int entries) { private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); } private static final class Holder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java index 9386dea..f346566 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java @@ -6,6 +6,7 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -56,6 +57,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N inventory.setItem(11, action(plugin, "confirm-yes", ACTION_CONFIRM, category.get())); inventory.setItem(15, action(plugin, "confirm-no", ACTION_CANCEL, category.get())); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -79,6 +81,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } if (action.get().equals(ACTION_CANCEL)) { + plugin.sounds().play(player, SoundKey.CANCEL); CreateCategoryMenu.openExisting(plugin, player, holder.categoryId()); return true; } @@ -97,6 +100,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player boolean deleted = plugin.customCategories().delete(holder.categoryId()); plugin.clearSearchCache(); player.sendMessage(Component.text(deleted ? "Category deleted: " : "Category already deleted: ", deleted ? NamedTextColor.GRAY : NamedTextColor.RED).append(Component.text(label, NamedTextColor.GOLD))); + plugin.sounds().play(player, deleted ? SoundKey.CATEGORY_DELETE : SoundKey.INVALID); plugin.guis().openBrowse(player); return true; } @@ -145,6 +149,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); } private static final class Holder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java index f2e95a5..45d6042 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java @@ -97,6 +97,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ renderEntries(plugin, inventory, categories, mode, page, plugin.adminModes().enabled(player)); renderControls(plugin, player, inventory, mode, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } private static void openEditor(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryEditorHolder holder) { @@ -113,6 +114,7 @@ private static void openEditor(@NotNull HeadDBPlugin plugin, @NotNull Player pla inventory.setItem(plugin.guiConfig().slot("more.category-create.back", 45), actionItem(plugin, ACTION_BACK, "back")); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -128,7 +130,10 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } Optional action = readAction(plugin, item); - action.ifPresent(value -> handleEditorAction(plugin, player, editor, value)); + action.ifPresent(value -> { + plugin.sounds().playGuiAction(player, value); + handleEditorAction(plugin, player, editor, value); + }); return true; } @@ -151,6 +156,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, event.getClick(), action.get()); return true; } @@ -381,6 +388,7 @@ private static void openConfirm(@NotNull HeadDBPlugin plugin, @NotNull Player pl inventory.setItem(11, actionItem(plugin, ACTION_REMOVE + id, "confirm-yes")); inventory.setItem(15, actionItem(plugin, ACTION_BACK, "confirm-no")); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } private static boolean canAdminCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player) { @@ -389,6 +397,7 @@ private static boolean canAdminCategories(@NotNull HeadDBPlugin plugin, @NotNull private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); } private static @NotNull ItemStack editAction(@NotNull HeadDBPlugin plugin, @NotNull String action, @NotNull String iconKey, @NotNull String label, @NotNull String value) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java index dffbf64..b98db8b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java @@ -99,6 +99,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, i renderEntries(plugin, player, inventory, entries, counts, customIds, page); renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -125,6 +126,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get()); return true; } @@ -354,6 +357,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); } private static final class Holder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java index 1d37a1a..ae60009 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java @@ -13,7 +13,9 @@ import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.jetbrains.annotations.NotNull; @@ -53,6 +55,7 @@ private GuiHeadIcons() { return; } + play(plugin, viewerId, icon.key()); action.accept(context); }); } @@ -82,6 +85,7 @@ private GuiHeadIcons() { return; } + play(plugin, viewerId, icon.key()); action.accept(context); }); } @@ -101,6 +105,7 @@ private GuiHeadIcons() { return; } + play(plugin, viewerId, icon.key()); action.accept(context); }); } @@ -153,6 +158,23 @@ private GuiHeadIcons() { return Optional.empty(); } + private static void play(@NotNull HeadDBPlugin plugin, @NotNull Optional viewerId, @NotNull String iconKey) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(viewerId, "viewerId"); + Objects.requireNonNull(iconKey, "iconKey"); + + if (viewerId.isEmpty()) { + return; + } + + Player player = Bukkit.getPlayer(viewerId.get()); + if (player == null) { + return; + } + + plugin.sounds().playGuiIcon(player, iconKey); + } + private static ItemStack configuredHead(@NotNull HeadDBPlugin plugin, @NotNull GuiIconConfig icon) { if (icon.headId().isBlank()) { plugin.getSLF4JLogger().warn( diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java index 228b1cf..efbfa41 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java @@ -94,6 +94,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N inventory.setItem(plugin.guiConfig().slot("gui-button.back", 18), action(plugin, ACTION_BACK, "back")); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -120,6 +121,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder.key(), action.get()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java index 4e2233f..47ef884 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java @@ -66,6 +66,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ renderControls(plugin, inventory, buttonKey, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -88,6 +89,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, event.getClick(), action.get()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java index 9c6cadf..8ca2898 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java @@ -10,6 +10,7 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -79,6 +80,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N inventory.setItem(SLOT_CANCEL, cancel); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -106,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } if (action.get().equals(ACTION_CANCEL)) { + plugin.sounds().play(player, SoundKey.CANCEL); HeadEditMenu.open(plugin, player, holder.id()); return true; } @@ -123,6 +126,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player if (!plugin.headRegistry().customHeads().delete(holder.id())) { player.closeInventory(); player.sendMessage(Component.text("Custom head no longer exists: ", NamedTextColor.RED).append(Component.text(holder.id().display(), NamedTextColor.GOLD))); + plugin.sounds().play(player, SoundKey.INVALID); return true; } @@ -131,6 +135,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player plugin.clearSearchCache(); player.closeInventory(); player.sendMessage(Component.text("Custom head deleted: ", NamedTextColor.GRAY).append(Component.text(label, NamedTextColor.GOLD))); + plugin.sounds().play(player, SoundKey.DELETE_CONFIRM); return true; } @@ -172,6 +177,7 @@ private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); } private static final class ConfirmHolder implements InventoryHolder { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java index f958cc8..fb0185e 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java @@ -27,6 +27,7 @@ import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead; import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverride; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; @@ -254,6 +255,7 @@ private void toggleFavorite(@NotNull Player player, @NotNull HeadId id) { boolean added = plugin.favorites().toggle(player.getUniqueId(), id); player.sendMessage(Component.text(added ? "Added favorite: " : "Removed favorite: ", added ? NamedTextColor.YELLOW : NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); + plugin.sounds().play(player, added ? SoundKey.FAVORITE_ADD : SoundKey.FAVORITE_REMOVE); } private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) { @@ -271,11 +273,13 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) { Optional head = plugin.headRegistry().find(id); if (head.isEmpty()) { player.sendMessage(Component.text("Head no longer exists.", NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.INVALID); return; } if (player.getInventory().firstEmpty() == -1) { player.sendMessage(Component.text("Your inventory is full.", NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.INVALID); return; } @@ -286,7 +290,11 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) { java.util.Map remaining = player.getInventory().addItem(item.clone()); if (!remaining.isEmpty()) { player.sendMessage(Component.text("Your inventory is full.", NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.INVALID); + return; } + + plugin.sounds().play(player, SoundKey.TAKE_HEAD); } private @NotNull Optional dropTarget(@NotNull InventoryClickEvent event, @NotNull ItemStack item) { @@ -300,6 +308,7 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) { private void openEdit(@NotNull Player player, @NotNull HeadId id) { if (!plugin.adminModes().enabled(player)) { player.sendMessage(Component.text("Enable Admin Mode to edit heads.", NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -342,6 +351,7 @@ private void handleAction(@NotNull Player player, @NotNull InventoryClickEvent e if (!plugin.adminModes().enabled(player)) { player.sendMessage(Component.text("Enable Admin Mode to edit heads.", NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -466,6 +476,7 @@ private void promptName(@NotNull Player player, @NotNull HeadId id) { } mutated(player, id); + plugin.sounds().play(player, SoundKey.SAVE); player.sendMessage(Component.text("Name Updated", NamedTextColor.GOLD, TextDecoration.BOLD)); player.sendMessage(Component.text(oldName, NamedTextColor.GRAY).append(Component.text(" > ", NamedTextColor.DARK_GRAY)).append(Component.text(value, NamedTextColor.GREEN))); HeadEditMenu.open(plugin, player, id); @@ -486,6 +497,7 @@ private void setCategory(@NotNull Player player, @NotNull HeadId id, @NotNull St } mutated(player, id); + plugin.sounds().play(player, SoundKey.SAVE); player.sendMessage(Component.text("Category set to ", NamedTextColor.GRAY).append(Component.text(GuiLabels.category(plugin, player, category), NamedTextColor.GOLD))); HeadEditMenu.open(plugin, player, id); } @@ -506,6 +518,7 @@ private void toggleTag(@NotNull Player player, @NotNull HeadId id, @NotNull Stri } mutated(player, id); + plugin.sounds().play(player, SoundKey.TOGGLE); HeadEditMenu.openTags(plugin, player, id, page); } @@ -525,6 +538,7 @@ private void toggleCollection(@NotNull Player player, @NotNull HeadId id, @NotNu } mutated(player, id); + plugin.sounds().play(player, SoundKey.TOGGLE); HeadEditMenu.openCollections(plugin, player, id, page); } @@ -538,6 +552,7 @@ private void toggleVisibility(@NotNull Player player, @NotNull HeadId id) { RemoteHeadOverride override = plugin.headRegistry().overrides().find(id).orElse(RemoteHeadOverride.empty(id, player.getUniqueId())); plugin.headRegistry().overrides().save(override.withHidden(!hidden, player.getUniqueId())); mutated(player, id); + plugin.sounds().play(player, hidden ? SoundKey.SHOW_HEAD : SoundKey.HIDE_HEAD); player.sendMessage(Component.text(hidden ? "Head is now visible." : "Head is now hidden.", hidden ? NamedTextColor.GREEN : NamedTextColor.GRAY)); HeadEditMenu.open(plugin, player, id); } @@ -550,6 +565,7 @@ private void reset(@NotNull Player player, @NotNull HeadId id) { plugin.headRegistry().overrides().delete(id); mutated(player, id); + plugin.sounds().play(player, SoundKey.SAVE); player.sendMessage(Component.text("Local override reset.", NamedTextColor.GRAY)); HeadEditMenu.open(plugin, player, id); } @@ -573,6 +589,7 @@ private void publishCustom(@NotNull Player player, @NotNull HeadId id) { StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id)); plugin.headRegistry().customHeads().save(stored.withDraft(false)); mutated(player, id); + plugin.sounds().play(player, SoundKey.PUBLISH); player.sendMessage(Component.text("Draft published: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD))); HeadEditMenu.open(plugin, player, id); } @@ -593,10 +610,13 @@ private void promptPrice(@NotNull Player player, @NotNull HeadId id) { double price = Double.parseDouble(value.trim()); plugin.economy().setHeadPrice(id, price); player.sendMessage(plugin.messages().priceUpdated(player, GuiLabels.head(plugin, player, id), plugin.economy().format(price))); + plugin.sounds().play(player, SoundKey.PRICE_SUCCESS); } catch (NumberFormatException exception) { player.sendMessage(plugin.messages().priceInvalid(player)); + plugin.sounds().play(player, SoundKey.PRICE_FAILURE); } catch (RuntimeException exception) { player.sendMessage(Component.text("Failed to update head price: " + exception.getMessage(), NamedTextColor.RED)); + plugin.sounds().play(player, SoundKey.PRICE_FAILURE); } HeadEditMenu.open(plugin, player, id); @@ -664,6 +684,7 @@ private void setLore(@NotNull Player player, @NotNull HeadId id, @NotNull List toggle(@NotNull Set values, @NotNull String value) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java index 63d79a4..6744735 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java @@ -113,6 +113,7 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player GuiItems.lore("There are no local override options for player heads.", NamedTextColor.DARK_GRAY) )), id, ACTION_PREVIEW)); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); return; } @@ -137,6 +138,7 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player } player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static void openCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id, int page) { @@ -192,6 +194,7 @@ public static void openLore(@NotNull HeadDBPlugin plugin, @NotNull Player player inventory.setItem(plugin.guiConfig().slot("edit.lore.clear", 50), button(plugin, id, ACTION_LORE_CLEAR, "lore-clear")); inventory.setItem(plugin.guiConfig().slot("edit.lore.reset", 53), button(plugin, id, ACTION_LORE_RESET, "lore-reset")); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static @NotNull Optional actionTarget(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { @@ -242,6 +245,7 @@ private static void openSelection(@NotNull HeadDBPlugin plugin, @NotNull Player inventory.setItem(SLOT_PREVIOUS, safePage > 0 ? button(plugin, id, ACTION_PREVIOUS, "previous") : inventory.getItem(SLOT_PREVIOUS)); inventory.setItem(SLOT_NEXT, safePage + 1 < pages ? button(plugin, id, ACTION_NEXT, "next") : inventory.getItem(SLOT_NEXT)); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } private static @NotNull Optional previewHead(@NotNull HeadDBPlugin plugin, @NotNull HeadId id) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java index a225b67..1dac063 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java @@ -14,7 +14,10 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -24,61 +27,48 @@ public final class FavoriteHeadService { private static final Component STAR = Component.text(" ★", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false); private final SQLiteDataSource dataSource; + private final Map> cache; public FavoriteHeadService(@NotNull Path databaseFile) { Objects.requireNonNull(databaseFile, "databaseFile"); this.dataSource = dataSource(databaseFile); + this.cache = new HashMap<>(); createSchema(); } public synchronized boolean isFavorite(@NotNull UUID playerId, @NotNull HeadId headId) { Objects.requireNonNull(playerId, "playerId"); Objects.requireNonNull(headId, "headId"); - - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM headdb_favorites WHERE player_id = ? AND head_id = ?")) { - statement.setString(1, playerId.toString()); - statement.setString(2, headId.toString()); - try (ResultSet result = statement.executeQuery()) { - return result.next(); - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to read HeadDB favorite.", exception); - } + return favorites(playerId).contains(headId); } public synchronized boolean toggle(@NotNull UUID playerId, @NotNull HeadId headId) { Objects.requireNonNull(playerId, "playerId"); Objects.requireNonNull(headId, "headId"); - if (isFavorite(playerId, headId)) { + Set favorites = new LinkedHashSet<>(favorites(playerId)); + if (favorites.contains(headId)) { remove(playerId, headId); + favorites.remove(headId); + cache.put(playerId, immutable(favorites)); return false; } add(playerId, headId); + favorites.add(headId); + cache.put(playerId, immutable(favorites)); return true; } public synchronized @NotNull Set favorites(@NotNull UUID playerId) { Objects.requireNonNull(playerId, "playerId"); - - LinkedHashSet ids = new LinkedHashSet<>(); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_favorites WHERE player_id = ? ORDER BY created_at ASC")) { - statement.setString(1, playerId.toString()); - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - try { - ids.add(new HeadId(result.getString("head_id"))); - } catch (IllegalArgumentException ignored) { - // Ignore stale malformed rows. - } - } - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to list HeadDB favorites.", exception); + Set cached = cache.get(playerId); + if (cached != null) { + return cached; } - - return Set.copyOf(ids); + Set loaded = loadFavorites(playerId); + cache.put(playerId, loaded); + return loaded; } public @NotNull ItemStack decorate(@NotNull UUID playerId, @NotNull HeadId headId, @NotNull ItemStack item) { @@ -102,6 +92,39 @@ public synchronized boolean toggle(@NotNull UUID playerId, @NotNull HeadId headI return item; } + public synchronized void invalidate(@NotNull UUID playerId) { + Objects.requireNonNull(playerId, "playerId"); + cache.remove(playerId); + } + + public synchronized void invalidateAll() { + cache.clear(); + } + + private @NotNull Set loadFavorites(@NotNull UUID playerId) { + LinkedHashSet ids = new LinkedHashSet<>(); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_favorites WHERE player_id = ? ORDER BY created_at ASC")) { + statement.setString(1, playerId.toString()); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + try { + ids.add(new HeadId(result.getString("head_id"))); + } catch (IllegalArgumentException ignored) { + // Ignore stale malformed rows. + } + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Failed to list HeadDB favorites.", exception); + } + + return immutable(ids); + } + + private static @NotNull Set immutable(@NotNull Set ids) { + return Collections.unmodifiableSet(new LinkedHashSet<>(ids)); + } + private void add(@NotNull UUID playerId, @NotNull HeadId headId) { try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT OR IGNORE INTO headdb_favorites(player_id, head_id, created_at) VALUES (?, ?, strftime('%s','now'))")) { statement.setString(1, playerId.toString()); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java index c707503..aad7d7f 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java @@ -73,6 +73,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, in renderHeads(plugin, player, inventory, heads, page); renderControls(plugin, inventory, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -96,6 +97,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player Optional action = readAction(plugin, item); if (action.isPresent()) { + plugin.sounds().playGuiAction(player, action.get()); handleAction(plugin, player, holder, action.get()); return true; } @@ -108,10 +110,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) { if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); return true; } - plugin.favorites().toggle(player.getUniqueId(), id.get()); + boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get()); + plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE); open(plugin, player, holder.page()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java index 2516627..a13eb8f 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java @@ -78,6 +78,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, in renderHeads(plugin, player, inventory, heads, page); renderControls(plugin, inventory, heads.size(), page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -92,6 +93,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player event.setCancelled(true); if (!Permissions.has(player, Permissions.GUI_HIDDEN_HEADS) || !plugin.adminModes().enabled(player)) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); player.closeInventory(); return true; } @@ -106,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player Optional action = readAction(plugin, item); if (action.isPresent()) { + plugin.sounds().playGuiAction(player, action.get()); handleAction(plugin, player, holder, action.get()); return true; } @@ -118,10 +121,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) { if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); return true; } - plugin.favorites().toggle(player.getUniqueId(), id.get()); + boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get()); + plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE); open(plugin, player, holder.page()); return true; } @@ -132,6 +137,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player plugin.clearItemCache(); plugin.clearSearchCache(); player.sendMessage(Component.text("Head shown: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id.get()), NamedTextColor.GOLD))); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.SHOW_HEAD); open(plugin, player, holder.page()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java index 604a76a..eaccbd8 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java @@ -108,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player Optional action = readAction(plugin, item); if (action.isPresent()) { + plugin.sounds().playGuiAction(player, action.get()); handleAction(plugin, player, holder, action.get()); return true; } @@ -125,10 +126,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) { if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) { player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); return true; } - plugin.favorites().toggle(player.getUniqueId(), id.get()); + boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get()); + plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE); open(plugin, player, holder.type(), holder.page()); return true; } @@ -155,6 +158,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ renderControls(plugin, inventory, type, page, pages, heads.size(), adminMode); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } private static void renderHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List heads, int page, boolean adminMode) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java index 33302bc..4a4274b 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java @@ -74,6 +74,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @ renderControls(plugin, inventory, page, pages); player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -100,6 +101,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player return true; } + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, action.get()); return true; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java index 7e25888..0362929 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java @@ -18,7 +18,6 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; -import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -31,6 +30,7 @@ import java.util.Locale; import java.util.Objects; import java.util.Set; +import java.util.UUID; @SuppressWarnings("UnstableApiUsage") public final class SearchFilterPageFactory implements PaperPageFactory { @@ -92,7 +92,7 @@ public SearchFilterPageFactory(@NotNull HeadDBPlugin plugin, @NotNull Mode mode) return page.build(); } - List entries = entries(player); + List entries = cachedEntries(context, player); int pageIndex = pageIndex(context); int fromIndex = pageIndex * ENTRY_SLOTS.length; @@ -286,7 +286,30 @@ private int selectedCount(@NotNull SearchRequest request) { }); } - private @NotNull List entries(@NotNull Player player) { + private @NotNull List cachedEntries(@NotNull GuiContext context, @NotNull Player player) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(player, "player"); + + String stateKey = cacheStateKey(); + FilterEntryCache cache = context.state().get(stateKey, FilterEntryCache.class); + UUID viewerId = player.getUniqueId(); + long registryRevision = plugin.headRegistry().revision(); + boolean adminMode = plugin.adminModes().enabled(player); + + if (cache != null && cache.matches(viewerId, registryRevision, adminMode)) { + return cache.entries(); + } + + List entries = entries(player, adminMode); + context.state().put(stateKey, new FilterEntryCache(viewerId, registryRevision, adminMode, entries)); + return entries; + } + + private @NotNull String cacheStateKey() { + return "headdb.search.filter.entries." + mode.name().toLowerCase(Locale.ROOT); + } + + private @NotNull List entries(@NotNull Player player, boolean adminMode) { if (mode == Mode.CATEGORY) { return plugin.headRegistry().categories().stream() .filter(category -> Permissions.canViewCategory(player, category.id())) @@ -296,42 +319,40 @@ private int selectedCount(@NotNull SearchRequest request) { } if (mode == Mode.TAGS) { - Set visibleTags = visibleTagIds(player); + Set visibleTags = visibleTagIds(player, adminMode); return plugin.headRegistry().tags().stream() - .filter(tag -> plugin.adminModes().enabled(player) || visibleTags.contains(tag.id())) + .filter(tag -> adminMode || visibleTags.contains(tag.id())) .sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER)) .map(tag -> new FilterEntry(tag.id(), tag.name())) .toList(); } - Set visibleCollections = visibleCollectionIds(player); + Set visibleCollections = visibleCollectionIds(player, adminMode); return plugin.headRegistry().collections().stream() - .filter(collection -> plugin.adminModes().enabled(player) || visibleCollections.contains(collection.id())) + .filter(collection -> adminMode || visibleCollections.contains(collection.id())) .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)) .map(collection -> new FilterEntry(collection.id(), collection.name())) .toList(); } - - private @NotNull Set visibleTagIds(@NotNull Player player) { + private @NotNull Set visibleTagIds(@NotNull Player player, boolean adminMode) { Set tags = new HashSet<>(); - for (Head head : visibleHeads(player)) { + for (Head head : visibleHeads(player, adminMode)) { tags.addAll(head.tags()); } return Set.copyOf(tags); } - private @NotNull Set visibleCollectionIds(@NotNull Player player) { + private @NotNull Set visibleCollectionIds(@NotNull Player player, boolean adminMode) { Set collections = new HashSet<>(); - for (Head head : visibleHeads(player)) { + for (Head head : visibleHeads(player, adminMode)) { collections.addAll(head.collections()); } return Set.copyOf(collections); } - private @NotNull List visibleHeads(@NotNull Player player) { - boolean includeHidden = plugin.adminModes().enabled(player); - return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList(); + private @NotNull List visibleHeads(@NotNull Player player, boolean adminMode) { + return plugin.headRegistry().heads(adminMode).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList(); } private int pageIndex(@NotNull GuiContext context) { @@ -372,6 +393,18 @@ private static void set(@NotNull PaperPageBuilder page, @NotNul private record FilterEntry(@NotNull String id, @NotNull String name) {} + private record FilterEntryCache(@NotNull UUID viewerId, long registryRevision, boolean adminMode, @NotNull List entries) { + + private FilterEntryCache { + Objects.requireNonNull(viewerId, "viewerId"); + entries = List.copyOf(Objects.requireNonNull(entries, "entries")); + } + + private boolean matches(@NotNull UUID viewerId, long registryRevision, boolean adminMode) { + return this.viewerId.equals(viewerId) && this.registryRevision == registryRevision && this.adminMode == adminMode; + } + } + public enum Mode { CATEGORY(CATEGORY_KEY, "Category Filters", "Categories", "headdb.search.filter.category-page", Permissions.GUI_FILTER_CATEGORIES), diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java index 1c8d76a..cc19cba 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java @@ -24,6 +24,7 @@ import net.kyori.adventure.text.format.TextDecoration; import io.github.silentdevelopment.headdb.paper.search.SearchRequest; import io.github.silentdevelopment.headdb.paper.search.SearchResultCache; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import io.github.silentdevelopment.headdb.query.HeadQueryResult; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -263,11 +264,13 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea if (!Permissions.canViewCategory(player, head.category())) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } if (player.getInventory().firstEmpty() == -1) { player.sendMessage(plugin.messages().giveInventoryFull(player, player)); + plugin.sounds().play(player, SoundKey.INVALID); return; } @@ -281,15 +284,18 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea item = itemFactory.create(head); } catch (IllegalArgumentException exception) { player.sendMessage(plugin.messages().invalidArgument(player, exception.getMessage())); + plugin.sounds().play(player, SoundKey.INVALID); return; } if (!player.getInventory().addItem(item).isEmpty()) { player.sendMessage(plugin.messages().giveInventoryFull(player, player)); + plugin.sounds().play(player, SoundKey.INVALID); return; } player.sendMessage(plugin.messages().giveSuccess(player, head, player)); + plugin.sounds().play(player, SoundKey.TAKE_HEAD); } private @NotNull HeadQueryResult search(Player player, @NotNull SearchRequest request, int page) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java index b3cf9f9..3b4d4d8 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/CommandConfirmMenu.java @@ -7,6 +7,7 @@ import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; import io.github.silentdevelopment.headdb.paper.message.MessageKey; import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.sound.SoundKey; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -43,6 +44,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N if (!Permissions.has(player, permission)) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return; } @@ -53,6 +55,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N inventory.setItem(11, action(plugin, "confirm-yes", ACTION_CONFIRM, command)); inventory.setItem(15, action(plugin, "confirm-no", ACTION_CANCEL, command)); player.openInventory(inventory); + plugin.sounds().play(player, SoundKey.MENU_OPEN); } public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { @@ -80,6 +83,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player } if (action.get().equals(ACTION_CANCEL)) { + plugin.sounds().play(player, SoundKey.CANCEL); plugin.guis().openMain(player); return true; } @@ -90,14 +94,33 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player if (!Permissions.has(player, holder.permission())) { player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, SoundKey.NO_PERMISSION); return true; } + plugin.sounds().play(player, soundFor(holder.command())); player.closeInventory(); player.performCommand(holder.command()); return true; } + private static @NotNull SoundKey soundFor(@NotNull String command) { + String normalized = command.trim().toLowerCase(java.util.Locale.ROOT); + if (normalized.startsWith("hdb reload") || normalized.equals("hdb reload")) { + return SoundKey.RELOAD; + } + if (normalized.startsWith("hdb refresh") || normalized.equals("hdb refresh")) { + return SoundKey.REFRESH; + } + if (normalized.startsWith("hdb verify") || normalized.equals("hdb verify")) { + return SoundKey.VERIFY; + } + if (normalized.startsWith("hdb debug") || normalized.equals("hdb debug")) { + return SoundKey.DEBUG; + } + return SoundKey.CONFIRM; + } + private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String command) { ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail("Command", "/" + command))); stamp(plugin, item, action); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/CachingHeadItemFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/CachingHeadItemFactory.java index 6250a64..6c81c95 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/CachingHeadItemFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/item/CachingHeadItemFactory.java @@ -8,41 +8,51 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.function.LongSupplier; public final class CachingHeadItemFactory implements HeadItemFactory { private static final int DEFAULT_MAX_SIZE = 4096; private final HeadItemFactory delegate; - private final Map cache; + private final LongSupplier revisionSupplier; + private final Map cache; public CachingHeadItemFactory(@NotNull HeadItemFactory delegate) { - this(delegate, DEFAULT_MAX_SIZE); + this(delegate, DEFAULT_MAX_SIZE, () -> 0L); } public CachingHeadItemFactory(@NotNull HeadItemFactory delegate, int maxSize) { + this(delegate, maxSize, () -> 0L); + } + + public CachingHeadItemFactory(@NotNull HeadItemFactory delegate, int maxSize, @NotNull LongSupplier revisionSupplier) { this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.revisionSupplier = Objects.requireNonNull(revisionSupplier, "revisionSupplier"); this.cache = new LruItemCache(maxSize); } @Override public @NotNull ItemStack create(@NotNull Head head) { Objects.requireNonNull(head, "head"); + CacheKey key = new CacheKey(head.id(), revisionSupplier.getAsLong()); synchronized (cache) { - ItemStack cached = cache.get(head.id()); + ItemStack cached = cache.get(key); if (cached != null) { return cached.clone(); } + } - ItemStack created = delegate.create(head); - ItemStack prototype = created.clone(); - - cache.put(head.id(), prototype); + ItemStack created = delegate.create(head); + ItemStack prototype = created.clone(); - return prototype.clone(); + synchronized (cache) { + cache.put(key, prototype); } + + return prototype.clone(); } public void clear() { @@ -57,7 +67,13 @@ public int size() { } } - private static final class LruItemCache extends LinkedHashMap { + private record CacheKey(@NotNull HeadId id, long revision) { + private CacheKey { + Objects.requireNonNull(id, "id"); + } + } + + private static final class LruItemCache extends LinkedHashMap { private final int maxSize; @@ -72,7 +88,7 @@ private LruItemCache(int maxSize) { } @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { if (maxSize == 0) { return false; } @@ -80,4 +96,4 @@ protected boolean removeEldestEntry(Map.Entry eldest) { return size() > maxSize; } } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java index 11260fd..f8f8a58 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/HeadRegistry.java @@ -23,10 +23,8 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; -import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -34,6 +32,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; public final class HeadRegistry implements io.github.silentdevelopment.headdb.registry.HeadRegistry { @@ -45,6 +44,8 @@ public final class HeadRegistry implements io.github.silentdevelopment.headdb.re private final CustomTaxonomyService customTagService; private final CustomTaxonomyService customCollectionService; private final HeadOverrideMerger merger; + private final AtomicLong revision; + private volatile RegistrySnapshot snapshot; public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull RemoteHeadOverrideStore overrideStore, @NotNull CustomHeadStore customHeadStore, @NotNull PlayerHeadService playerHeadService, @NotNull CustomTaxonomyService customTagService, @NotNull CustomTaxonomyService customCollectionService) { this.remoteDatabase = Objects.requireNonNull(remoteDatabase, "remoteDatabase"); @@ -54,6 +55,7 @@ public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull Remote this.customTagService = Objects.requireNonNull(customTagService, "customTagService"); this.customCollectionService = Objects.requireNonNull(customCollectionService, "customCollectionService"); this.merger = new HeadOverrideMerger(); + this.revision = new AtomicLong(); } public @NotNull DatabaseStatus status() { @@ -69,12 +71,8 @@ public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull Remote public @NotNull Optional find(@NotNull HeadId id) { Objects.requireNonNull(id, "id"); - if (id.isRemote()) { - return remoteDatabase.findById(id).map(this::effectiveRemoteHead); - } - - if (id.isCustom()) { - return customHeadStore.find(id); + if (id.isRemote() || id.isCustom()) { + return snapshot().head(id); } return playerHeadService.resolveCached(id.key()); @@ -98,7 +96,7 @@ public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull Remote public @NotNull HeadQueryResult search(@NotNull HeadQuery query) { Objects.requireNonNull(query, "query"); - List matches = filter(searchableHeads(false), query).sorted(comparator(query)).toList(); + List matches = searchAll(query, false); int from = Math.min(query.offset(), matches.size()); int to = Math.min(from + query.limit(), matches.size()); return new HeadQueryResult(matches.subList(from, to), matches.size(), query.offset(), query.limit()); @@ -107,39 +105,28 @@ public HeadRegistry(@NotNull DefaultHeadDatabase remoteDatabase, @NotNull Remote public @NotNull HeadQueryResult searchIncludingHidden(@NotNull HeadQuery query) { Objects.requireNonNull(query, "query"); - List matches = filter(searchableHeads(true), query).sorted(comparator(query)).toList(); + List matches = searchAll(query, true); int from = Math.min(query.offset(), matches.size()); int to = Math.min(from + query.limit(), matches.size()); return new HeadQueryResult(matches.subList(from, to), matches.size(), query.offset(), query.limit()); } + public @NotNull List searchAll(@NotNull HeadQuery query, boolean includeHidden) { + Objects.requireNonNull(query, "query"); + return filter(snapshot().heads(includeHidden), query).sorted(comparator(query)).toList(); + } + public @NotNull List heads(boolean includeHidden) { - return searchableHeads(includeHidden); + return snapshot().heads(includeHidden); } public @NotNull List hiddenHeads() { - Map overrides = remoteOverridesById(); - List hidden = new ArrayList<>(); - for (Head remote : remoteDatabase.heads()) { - RemoteHeadOverride override = overrides.get(remote.id()); - if (override == null || !Boolean.TRUE.equals(override.hidden())) { - continue; - } - - hidden.add(merger.merge(remote, override)); - } - - return hidden.stream().sorted(Comparator.comparing(Head::category).thenComparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList(); + return snapshot().hiddenHeads(); } public @NotNull Map categoryCounts(boolean includeHidden) { - Map counts = new LinkedHashMap<>(); - for (Head head : searchableHeads(includeHidden)) { - counts.merge(head.category(), 1, Integer::sum); - } - - return Map.copyOf(counts); + return snapshot().categoryCounts(includeHidden); } public boolean hidden(@NotNull HeadId id) { @@ -148,35 +135,180 @@ public boolean hidden(@NotNull HeadId id) { return false; } - return overrideStore.find(id).map(RemoteHeadOverride::hidden).map(Boolean.TRUE::equals).orElse(false); + return snapshot().hidden(id); } public @NotNull Optional category(@NotNull String id) { String normalized = normalizeId(id); - return categories().stream().filter(category -> category.id().equals(normalized)).findFirst(); + return snapshot().category(normalized); } public @NotNull Optional tag(@NotNull String id) { String normalized = normalizeId(id); - return tags().stream().filter(tag -> tag.id().equals(normalized)).findFirst(); + return snapshot().tag(normalized); } public @NotNull Optional collection(@NotNull String id) { String normalized = normalizeId(id); - return collections().stream().filter(collection -> collection.id().equals(normalized)).findFirst(); + return snapshot().collection(normalized); } public @NotNull List categories() { + return snapshot().categories(); + } + + public @NotNull List tags() { + return snapshot().tags(); + } + + public @NotNull List collections() { + return snapshot().collections(); + } + + public @NotNull Optional> lore(@NotNull HeadId id) { + Objects.requireNonNull(id, "id"); + + if (!id.isRemote() && !id.isCustom()) { + return Optional.empty(); + } + + return snapshot().lore(id); + } + + public @NotNull RemoteHeadOverrideStore overrides() { + return overrideStore; + } + + public @NotNull CustomHeadStore customHeads() { + return customHeadStore; + } + + public @NotNull PlayerHeadService playerHeads() { + return playerHeadService; + } + + public void onLocalMutation() { + invalidate(); + } + + public synchronized void invalidate() { + snapshot = null; + revision.incrementAndGet(); + } + + public long revision() { + return revision.get(); + } + + public void warm() { + snapshot(); + } + + private @NotNull RegistrySnapshot snapshot() { + RegistrySnapshot current = snapshot; + + if (current != null) { + return current; + } + + synchronized (this) { + current = snapshot; + + if (current != null) { + return current; + } + + RegistrySnapshot created = createSnapshot(); + snapshot = created; + return created; + } + } + + private @NotNull RegistrySnapshot createSnapshot() { + List storedCustomHeads = List.copyOf(customHeadStore.listStored()); + List customHeads = storedCustomHeads.stream().filter(head -> !head.draft()).map(StoredCustomHead::toHead).toList(); + List overrides = List.copyOf(overrideStore.list()); + Map overridesById = remoteOverridesById(overrides); + Map headsById = new LinkedHashMap<>(); + Map> loreById = new LinkedHashMap<>(); + Set hiddenIds = new java.util.LinkedHashSet<>(); + List heads = new ArrayList<>(); + List headsIncludingHidden = new ArrayList<>(); + List hiddenHeads = new ArrayList<>(); + + for (RemoteHeadOverride override : overrides) { + if (override.lore() != null) { + loreById.put(override.headId(), override.lore()); + } + } + + for (StoredCustomHead head : storedCustomHeads) { + loreById.put(head.headId(), head.lore()); + } + + for (Head remote : remoteDatabase.heads()) { + RemoteHeadOverride override = overridesById.get(remote.id()); + Head effective = override == null ? remote : merger.merge(remote, override); + boolean hidden = override != null && Boolean.TRUE.equals(override.hidden()); + + headsById.put(effective.id(), effective); + headsIncludingHidden.add(effective); + + if (hidden) { + hiddenIds.add(effective.id()); + hiddenHeads.add(effective); + continue; + } + + heads.add(effective); + } + + for (Head custom : customHeads) { + headsById.put(custom.id(), custom); + } + heads.addAll(customHeads); + headsIncludingHidden.addAll(customHeads); + + List categories = categories(customHeads, overrides); + List tags = tags(customHeads, overrides); + List collections = collections(customHeads); + return new RegistrySnapshot( + heads, + headsIncludingHidden, + hiddenHeads.stream().sorted(Comparator.comparing(Head::category).thenComparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList(), + categoryCounts(heads), + categoryCounts(headsIncludingHidden), + categories, + tags, + collections, + headsById, + loreById, + hiddenIds, + categoriesById(categories), + tagsById(tags), + collectionsById(collections) + ); + } + + private @NotNull Map categoryCounts(@NotNull List heads) { + Map counts = new LinkedHashMap<>(); + for (Head head : heads) { + counts.merge(head.category(), 1, Integer::sum); + } + return Map.copyOf(counts); + } + + private @NotNull List categories(@NotNull List customHeads, @NotNull List overrides) { Map remoteCategories = new LinkedHashMap<>(); for (HeadCategory category : remoteDatabase.categories()) { remoteCategories.put(category.id(), category); } Map localCategories = new LinkedHashMap<>(); - for (Head head : customHeadStore.list()) { + for (Head head : customHeads) { localCategories.putIfAbsent(head.category(), new HeadCategory(head.category(), displayName(head.category()), "Local custom category.")); } - for (RemoteHeadOverride override : overrideStore.list()) { + for (RemoteHeadOverride override : overrides) { if (override.category() == null) { continue; } @@ -190,17 +322,17 @@ public boolean hidden(@NotNull HeadId id) { return List.copyOf(categories); } - public @NotNull List tags() { + private @NotNull List tags(@NotNull List customHeads, @NotNull List overrides) { Map tags = new LinkedHashMap<>(); for (HeadTag tag : remoteDatabase.tags()) { tags.put(tag.id(), tag); } - for (Head head : customHeadStore.list()) { + for (Head head : customHeads) { for (String tag : head.tags()) { tags.putIfAbsent(tag, new HeadTag(tag, displayName(tag), "Local tag.")); } } - for (RemoteHeadOverride override : overrideStore.list()) { + for (RemoteHeadOverride override : overrides) { for (String tag : override.addTags()) { tags.putIfAbsent(tag, new HeadTag(tag, displayName(tag), "Local override tag.")); } @@ -216,12 +348,12 @@ public boolean hidden(@NotNull HeadId id) { return tags.values().stream().sorted(Comparator.comparing(HeadTag::id)).toList(); } - public @NotNull List collections() { + private @NotNull List collections(@NotNull List customHeads) { Map collections = new LinkedHashMap<>(); for (HeadCollection collection : remoteDatabase.collections()) { collections.put(collection.id(), collection); } - for (Head head : customHeadStore.list()) { + for (Head head : customHeads) { for (String collection : head.collections()) { collections.putIfAbsent(collection, new HeadCollection(collection, displayName(collection), "Local collection.")); } @@ -232,63 +364,36 @@ public boolean hidden(@NotNull HeadId id) { return collections.values().stream().sorted(Comparator.comparing(HeadCollection::id)).toList(); } - public @NotNull Optional> lore(@NotNull HeadId id) { - Objects.requireNonNull(id, "id"); - - if (id.isRemote()) { - return overrideStore.find(id).map(RemoteHeadOverride::lore).filter(Objects::nonNull); - } - - if (id.isCustom()) { - return customHeadStore.findStored(id).map(StoredCustomHead::lore); + private @NotNull Map remoteOverridesById(@NotNull List overrides) { + Map overridesById = new LinkedHashMap<>(); + for (RemoteHeadOverride override : overrides) { + overridesById.put(override.headId(), override); } - - return Optional.empty(); + return overridesById; } - public @NotNull RemoteHeadOverrideStore overrides() { - return overrideStore; - } - - public @NotNull CustomHeadStore customHeads() { - return customHeadStore; - } - - public @NotNull PlayerHeadService playerHeads() { - return playerHeadService; - } - - public void onLocalMutation() { - // Hook kept for future in-memory index invalidation. - } - - private @NotNull List searchableHeads(boolean includeHidden) { - Map overrides = remoteOverridesById(); - List heads = new ArrayList<>(); - - for (Head remote : remoteDatabase.heads()) { - RemoteHeadOverride override = overrides.get(remote.id()); - if (!includeHidden && override != null && Boolean.TRUE.equals(override.hidden())) { - continue; - } - - heads.add(override == null ? remote : merger.merge(remote, override)); + private @NotNull Map categoriesById(@NotNull List categories) { + Map categoriesById = new LinkedHashMap<>(); + for (HeadCategory category : categories) { + categoriesById.put(category.id(), category); } - - heads.addAll(customHeadStore.list()); - return heads; + return categoriesById; } - private @NotNull Map remoteOverridesById() { - Map overrides = new LinkedHashMap<>(); - for (RemoteHeadOverride override : overrideStore.list()) { - overrides.put(override.headId(), override); + private @NotNull Map tagsById(@NotNull List tags) { + Map tagsById = new LinkedHashMap<>(); + for (HeadTag tag : tags) { + tagsById.put(tag.id(), tag); } - return overrides; + return tagsById; } - private @NotNull Head effectiveRemoteHead(@NotNull Head head) { - return overrideStore.find(head.id()).map(override -> merger.merge(head, override)).orElse(head); + private @NotNull Map collectionsById(@NotNull List collections) { + Map collectionsById = new LinkedHashMap<>(); + for (HeadCollection collection : collections) { + collectionsById.put(collection.id(), collection); + } + return collectionsById; } private @NotNull Stream filter(@NotNull List heads, @NotNull HeadQuery query) { @@ -412,6 +517,81 @@ private boolean isNumeric(@NotNull String value) { return true; } + private record RegistrySnapshot( + @NotNull List heads, + @NotNull List headsIncludingHidden, + @NotNull List hiddenHeads, + @NotNull Map categoryCounts, + @NotNull Map categoryCountsIncludingHidden, + @NotNull List categories, + @NotNull List tags, + @NotNull List collections, + @NotNull Map headsById, + @NotNull Map> loreById, + @NotNull Set hiddenIds, + @NotNull Map categoriesById, + @NotNull Map tagsById, + @NotNull Map collectionsById + ) { + + private RegistrySnapshot { + heads = List.copyOf(Objects.requireNonNull(heads, "heads")); + headsIncludingHidden = List.copyOf(Objects.requireNonNull(headsIncludingHidden, "headsIncludingHidden")); + hiddenHeads = List.copyOf(Objects.requireNonNull(hiddenHeads, "hiddenHeads")); + categoryCounts = Map.copyOf(Objects.requireNonNull(categoryCounts, "categoryCounts")); + categoryCountsIncludingHidden = Map.copyOf(Objects.requireNonNull(categoryCountsIncludingHidden, "categoryCountsIncludingHidden")); + categories = List.copyOf(Objects.requireNonNull(categories, "categories")); + tags = List.copyOf(Objects.requireNonNull(tags, "tags")); + collections = List.copyOf(Objects.requireNonNull(collections, "collections")); + headsById = Map.copyOf(Objects.requireNonNull(headsById, "headsById")); + loreById = copyLore(Objects.requireNonNull(loreById, "loreById")); + hiddenIds = Set.copyOf(Objects.requireNonNull(hiddenIds, "hiddenIds")); + categoriesById = Map.copyOf(Objects.requireNonNull(categoriesById, "categoriesById")); + tagsById = Map.copyOf(Objects.requireNonNull(tagsById, "tagsById")); + collectionsById = Map.copyOf(Objects.requireNonNull(collectionsById, "collectionsById")); + } + + private @NotNull List heads(boolean includeHidden) { + return includeHidden ? headsIncludingHidden : heads; + } + + private @NotNull Optional head(@NotNull HeadId id) { + return Optional.ofNullable(headsById.get(id)); + } + + private boolean hidden(@NotNull HeadId id) { + return hiddenIds.contains(id); + } + + private @NotNull Optional> lore(@NotNull HeadId id) { + return Optional.ofNullable(loreById.get(id)); + } + + private @NotNull Optional category(@NotNull String id) { + return Optional.ofNullable(categoriesById.get(id)); + } + + private @NotNull Optional tag(@NotNull String id) { + return Optional.ofNullable(tagsById.get(id)); + } + + private @NotNull Optional collection(@NotNull String id) { + return Optional.ofNullable(collectionsById.get(id)); + } + + private @NotNull Map categoryCounts(boolean includeHidden) { + return includeHidden ? categoryCountsIncludingHidden : categoryCounts; + } + + private static @NotNull Map> copyLore(@NotNull Map> source) { + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(copy); + } + } + private static @NotNull String normalizeId(@NotNull String id) { String normalized = id.trim().toLowerCase(Locale.ROOT); if (normalized.isEmpty()) { diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CachedCustomHeadStore.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CachedCustomHeadStore.java new file mode 100644 index 0000000..77160bd --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/custom/CachedCustomHeadStore.java @@ -0,0 +1,79 @@ +package io.github.silentdevelopment.headdb.paper.local.custom; + +import io.github.silentdevelopment.headdb.model.HeadId; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class CachedCustomHeadStore implements CustomHeadStore { + + private final CustomHeadStore delegate; + private Map cache; + + public CachedCustomHeadStore(@NotNull CustomHeadStore delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public synchronized @NotNull Optional findStored(@NotNull HeadId id) { + Objects.requireNonNull(id, "id"); + if (!id.isCustom()) { + return Optional.empty(); + } + return Optional.ofNullable(cache().get(id)); + } + + @Override + public synchronized @NotNull Collection listStored() { + return List.copyOf(cache().values()); + } + + @Override + public synchronized void save(@NotNull StoredCustomHead head) { + Objects.requireNonNull(head, "head"); + delegate.save(head); + Map updated = new LinkedHashMap<>(cache()); + updated.put(head.headId(), head); + cache = Collections.unmodifiableMap(updated); + } + + @Override + public synchronized boolean delete(@NotNull HeadId id) { + Objects.requireNonNull(id, "id"); + if (!id.isCustom()) { + return false; + } + boolean deleted = delegate.delete(id); + if (!deleted) { + return false; + } + Map updated = new LinkedHashMap<>(cache()); + updated.remove(id); + cache = Collections.unmodifiableMap(updated); + return true; + } + + public synchronized void invalidate() { + cache = null; + } + + private @NotNull Map cache() { + Map current = cache; + if (current != null) { + return current; + } + Map loaded = new LinkedHashMap<>(); + for (StoredCustomHead head : delegate.listStored()) { + loaded.put(head.headId(), head); + } + current = Collections.unmodifiableMap(loaded); + cache = current; + return current; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/override/CachedRemoteHeadOverrideStore.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/override/CachedRemoteHeadOverrideStore.java new file mode 100644 index 0000000..4cc17bb --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/override/CachedRemoteHeadOverrideStore.java @@ -0,0 +1,97 @@ +package io.github.silentdevelopment.headdb.paper.local.override; + +import io.github.silentdevelopment.headdb.model.HeadId; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class CachedRemoteHeadOverrideStore implements RemoteHeadOverrideStore { + + private final RemoteHeadOverrideStore delegate; + private Map cache; + + public CachedRemoteHeadOverrideStore(@NotNull RemoteHeadOverrideStore delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public synchronized @NotNull Optional find(@NotNull HeadId id) { + Objects.requireNonNull(id, "id"); + if (!id.isRemote()) { + return Optional.empty(); + } + return Optional.ofNullable(cache().get(id)); + } + + @Override + public synchronized @NotNull Collection list() { + return List.copyOf(cache().values()); + } + + @Override + public synchronized void save(@NotNull RemoteHeadOverride override) { + Objects.requireNonNull(override, "override"); + delegate.save(override); + Map updated = new LinkedHashMap<>(cache()); + if (override.empty()) { + updated.remove(override.headId()); + } else { + updated.put(override.headId(), override); + } + cache = Collections.unmodifiableMap(updated); + } + + @Override + public synchronized boolean delete(@NotNull HeadId id) { + Objects.requireNonNull(id, "id"); + if (!id.isRemote()) { + return false; + } + boolean deleted = delegate.delete(id); + if (!deleted) { + return false; + } + Map updated = new LinkedHashMap<>(cache()); + updated.remove(id); + cache = Collections.unmodifiableMap(updated); + return true; + } + + @Override + public synchronized int deleteOrphans(@NotNull Set validRemoteIds) { + Objects.requireNonNull(validRemoteIds, "validRemoteIds"); + int deleted = delegate.deleteOrphans(validRemoteIds); + if (deleted <= 0) { + return 0; + } + Map updated = new LinkedHashMap<>(cache()); + updated.keySet().removeIf(id -> !validRemoteIds.contains(id)); + cache = Collections.unmodifiableMap(updated); + return deleted; + } + + public synchronized void invalidate() { + cache = null; + } + + private @NotNull Map cache() { + Map current = cache; + if (current != null) { + return current; + } + Map loaded = new LinkedHashMap<>(); + for (RemoteHeadOverride override : delegate.list()) { + loaded.put(override.headId(), override); + } + current = Collections.unmodifiableMap(loaded); + cache = current; + return current; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java index a7350ff..cd3e541 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/local/taxonomy/CustomTaxonomyService.java @@ -24,6 +24,7 @@ public final class CustomTaxonomyService { private final SQLiteDataSource dataSource; private final String type; private final String defaultDescription; + private List cache; public CustomTaxonomyService(@NotNull Path databaseFile, @NotNull String type, @NotNull String defaultDescription) { Objects.requireNonNull(databaseFile, "databaseFile"); @@ -34,38 +35,19 @@ public CustomTaxonomyService(@NotNull Path databaseFile, @NotNull String type, @ } public synchronized @NotNull List list() { - List entries = new ArrayList<>(); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, description, created_at, updated_at, created_by FROM headdb_custom_taxonomy WHERE type = ? ORDER BY lower(name) ASC")) { - statement.setString(1, type); - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - entries.add(entry(result)); - } - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to list HeadDB custom " + type + " entries.", exception); + List current = cache; + if (current != null) { + return current; } - - entries.sort(Comparator.comparing(CustomTaxonomyEntry::name, String.CASE_INSENSITIVE_ORDER)); - return List.copyOf(entries); + current = loadAll(); + cache = current; + return current; } public synchronized @NotNull Optional find(@NotNull String id) { Objects.requireNonNull(id, "id"); String normalized = CustomTaxonomyEntry.normalizeId(id); - try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, description, created_at, updated_at, created_by FROM headdb_custom_taxonomy WHERE type = ? AND id = ?")) { - statement.setString(1, type); - statement.setString(2, normalized); - try (ResultSet result = statement.executeQuery()) { - if (!result.next()) { - return Optional.empty(); - } - - return Optional.of(entry(result)); - } - } catch (SQLException exception) { - throw new IllegalStateException("Failed to read HeadDB custom " + type + " entry " + normalized + ".", exception); - } + return list().stream().filter(entry -> entry.id().equals(normalized)).findFirst(); } public synchronized void save(@NotNull CustomTaxonomyEntry entry) { @@ -79,6 +61,7 @@ public synchronized void save(@NotNull CustomTaxonomyEntry entry) { statement.setString(6, entry.updatedAt().toString()); statement.setString(7, entry.createdBy() == null ? null : entry.createdBy().toString()); statement.executeUpdate(); + cache = null; } catch (SQLException exception) { throw new IllegalStateException("Failed to save HeadDB custom " + type + " entry " + entry.id() + ".", exception); } @@ -96,12 +79,33 @@ public synchronized boolean delete(@NotNull String id) { try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("DELETE FROM headdb_custom_taxonomy WHERE type = ? AND id = ?")) { statement.setString(1, type); statement.setString(2, normalized); - return statement.executeUpdate() > 0; + boolean deleted = statement.executeUpdate() > 0; + if (deleted) { + cache = null; + } + return deleted; } catch (SQLException exception) { throw new IllegalStateException("Failed to delete HeadDB custom " + type + " entry " + normalized + ".", exception); } } + private @NotNull List loadAll() { + List entries = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, description, created_at, updated_at, created_by FROM headdb_custom_taxonomy WHERE type = ? ORDER BY lower(name) ASC")) { + statement.setString(1, type); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + entries.add(entry(result)); + } + } + } catch (SQLException exception) { + throw new IllegalStateException("Failed to list HeadDB custom " + type + " entries.", exception); + } + + entries.sort(Comparator.comparing(CustomTaxonomyEntry::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(entries); + } + private @NotNull CustomTaxonomyEntry entry(@NotNull ResultSet result) throws SQLException { String description = result.getString("description"); return new CustomTaxonomyEntry( diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/runtime/PluginRuntime.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/runtime/PluginRuntime.java index bef8d87..846fd3f 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/runtime/PluginRuntime.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/runtime/PluginRuntime.java @@ -187,6 +187,8 @@ private void loadCachedStarted() { return; } + plugin.invalidateHeadRegistry(); + plugin.warmHeadRegistry(); plugin.clearItemCache(); plugin.clearSearchCache(); refreshState.markSuccess(); @@ -209,6 +211,8 @@ private void refreshRemote() { private void refreshRemoteStarted() { try { refreshService.refresh(); + plugin.invalidateHeadRegistry(); + plugin.warmHeadRegistry(); plugin.clearItemCache(); plugin.clearSearchCache(); refreshState.markSuccess(); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchResultCache.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchResultCache.java index e53aee6..d3f46e8 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchResultCache.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/search/SearchResultCache.java @@ -1,21 +1,24 @@ package io.github.silentdevelopment.headdb.paper.search; -import io.github.silentdevelopment.headdb.paper.local.HeadRegistry; import io.github.silentdevelopment.headdb.database.DatabaseStatus; +import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.model.HeadId; +import io.github.silentdevelopment.headdb.paper.local.HeadRegistry; +import io.github.silentdevelopment.headdb.query.HeadQuery; import io.github.silentdevelopment.headdb.query.HeadQueryResult; import io.github.silentdevelopment.headdb.query.HeadSort; import io.github.silentdevelopment.headdb.query.SortDirection; import org.jetbrains.annotations.NotNull; import java.time.Duration; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public final class SearchResultCache { - private static final int DEFAULT_MAX_ENTRIES = 128; + private static final int DEFAULT_MAX_ENTRIES = 32; private static final Duration DEFAULT_TTL = Duration.ofSeconds(10); private final ConcurrentHashMap cache; @@ -52,18 +55,19 @@ public SearchResultCache(int maxEntries, @NotNull Duration ttl) { long now = System.nanoTime(); DatabaseStatus status = registry.status(); - Key key = Key.create(status, request, page, limit, includeHidden); + Key key = Key.create(status, registry.revision(), request, includeHidden); Entry cached = cache.get(key); if (cached != null && !cached.expired(now, ttlNanos)) { - return cached.result(); + return cached.result(page, limit); } - HeadQueryResult result = includeHidden ? registry.searchIncludingHidden(SearchQueries.query(request, page, limit)) : registry.search(SearchQueries.query(request, page, limit)); - cache.put(key, new Entry(result, now)); + HeadQuery query = SearchQueries.query(request, 1, Math.min(limit, HeadQuery.MAX_LIMIT)); + Entry created = new Entry(registry.searchAll(query, includeHidden), now); + cache.put(key, created); clean(now); - return result; + return created.result(page, limit); } public void clear() { @@ -88,10 +92,17 @@ private void clean(long now) { cache.clear(); } - private record Entry(@NotNull HeadQueryResult result, long createdAtNanos) { + private record Entry(@NotNull List heads, long createdAtNanos) { private Entry { - Objects.requireNonNull(result, "result"); + heads = List.copyOf(Objects.requireNonNull(heads, "heads")); + } + + private @NotNull HeadQueryResult result(int page, int limit) { + int offset = Math.max(0, (Math.max(1, page) - 1) * Math.max(1, limit)); + int from = Math.min(offset, heads.size()); + int to = Math.min(from + Math.max(1, limit), heads.size()); + return new HeadQueryResult(heads.subList(from, to), heads.size(), offset, Math.max(1, limit)); } private boolean expired(long now, long ttlNanos) { @@ -105,6 +116,7 @@ private record Key( @NotNull String manifestId, @NotNull String artifactId, @NotNull String loadedAt, + long registryRevision, @NotNull String query, @NotNull Set ids, @NotNull Set categories, @@ -112,8 +124,6 @@ private record Key( @NotNull Set collections, @NotNull HeadSort sort, @NotNull SortDirection direction, - int page, - int limit, boolean categoryLocked, boolean includeHidden ) { @@ -133,7 +143,7 @@ private record Key( Objects.requireNonNull(direction, "direction"); } - private static @NotNull Key create(@NotNull DatabaseStatus status, @NotNull SearchRequest request, int page, int limit, boolean includeHidden) { + private static @NotNull Key create(@NotNull DatabaseStatus status, long registryRevision, @NotNull SearchRequest request, boolean includeHidden) { Objects.requireNonNull(status, "status"); Objects.requireNonNull(request, "request"); @@ -143,6 +153,7 @@ private record Key( text(status.manifestId()), text(status.artifactId()), status.loadedAt() == null ? "" : status.loadedAt().toString(), + registryRevision, request.query(), request.ids(), request.categories(), @@ -150,8 +161,6 @@ private record Key( request.collections(), request.sort(), request.direction(), - page, - limit, request.categoryLocked(), includeHidden ); @@ -165,4 +174,4 @@ private record Key( return value; } } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/HeadSoundService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/HeadSoundService.java new file mode 100644 index 0000000..19bd7d3 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/HeadSoundService.java @@ -0,0 +1,123 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class HeadSoundService { + + private static final long DUPLICATE_WINDOW_NANOS = 60_000_000L; + + private final HeadDBPlugin plugin; + private final SoundConfig config; + private final Set warnedInvalidSounds = ConcurrentHashMap.newKeySet(); + private final Map lastPlayed = new ConcurrentHashMap<>(); + + public HeadSoundService(@NotNull HeadDBPlugin plugin, @NotNull SoundConfig config) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.config = Objects.requireNonNull(config, "config"); + } + + public void play(@NotNull Player player, @NotNull SoundKey key) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(key, "key"); + + if (!config.enabled()) { + return; + } + + if (!player.isOnline()) { + return; + } + + SoundEntry entry = config.entry(key); + if (!entry.enabled()) { + return; + } + + if (entry.volume() <= 0.0F) { + return; + } + + if (isDuplicate(player, key)) { + return; + } + + Location location = player.getLocation(); + String rawSound = entry.sound().trim(); + if (rawSound.isBlank()) { + warnInvalid(key, rawSound); + return; + } + + if (isNamespacedSound(rawSound)) { + player.playSound(location, rawSound.toLowerCase(Locale.ROOT), entry.volume(), entry.pitch()); + return; + } + + Sound sound; + try { + sound = Sound.valueOf(rawSound.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + warnInvalid(key, rawSound); + return; + } + + player.playSound(location, sound, entry.volume(), entry.pitch()); + } + + public void playGuiAction(@NotNull Player player, @NotNull String action) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(action, "action"); + play(player, SoundKey.fromGuiAction(action)); + } + + public void playGuiIcon(@NotNull Player player, @NotNull String iconKey) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(iconKey, "iconKey"); + play(player, SoundKey.fromGuiIcon(iconKey)); + } + + private boolean isDuplicate(@NotNull Player player, @NotNull SoundKey key) { + long now = System.nanoTime(); + PlayedSound previous = lastPlayed.put(player.getUniqueId(), new PlayedSound(key, now)); + if (previous == null || now - previous.timeNanos() >= DUPLICATE_WINDOW_NANOS) { + return false; + } + + return previous.key() == key || suppressesAfterAnyRecentSound(key); + } + + private static boolean suppressesAfterAnyRecentSound(@NotNull SoundKey key) { + return switch (key) { + case MENU_OPEN, CLICK, BACK, PAGE_NEXT, PAGE_PREVIOUS, SEARCH, OPEN_INPUT, SELECTOR_OPEN, TOGGLE, SETTINGS, LANGUAGE, CUSTOM_MANAGEMENT -> true; + default -> false; + }; + } + + private void warnInvalid(@NotNull SoundKey key, @NotNull String sound) { + String value = sound.isBlank() ? "" : sound; + String warningKey = key.configKey() + "=" + value; + if (!warnedInvalidSounds.add(warningKey)) { + return; + } + + plugin.getSLF4JLogger().warn("Invalid sound '{}' configured for sounds.{}. The sound will be skipped.", value, key.configKey()); + } + + private static boolean isNamespacedSound(@NotNull String sound) { + String normalized = sound.trim().toLowerCase(Locale.ROOT); + return normalized.contains(":") || normalized.contains("."); + } + + private record PlayedSound(@NotNull SoundKey key, long timeNanos) {} +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfig.java new file mode 100644 index 0000000..4a7bee5 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfig.java @@ -0,0 +1,121 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import io.github.silentdevelopment.headdb.paper.config.ConfigException; +import org.jetbrains.annotations.NotNull; + +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public final class SoundConfig { + + private final boolean enabled; + private final Map entries; + + private SoundConfig(boolean enabled, @NotNull Map entries) { + this.enabled = enabled; + this.entries = Map.copyOf(Objects.requireNonNull(entries, "entries")); + } + + public boolean enabled() { + return enabled; + } + + public @NotNull SoundEntry entry(@NotNull SoundKey key) { + Objects.requireNonNull(key, "key"); + SoundEntry entry = entries.get(key); + + if (entry != null) { + return entry; + } + + return new SoundEntry(false, "UI_BUTTON_CLICK", 0.0F, 1.0F); + } + + public static @NotNull SoundConfig fromMap(@NotNull Map root) { + Objects.requireNonNull(root, "root"); + Map sounds = section(root, "sounds"); + boolean enabled = booleanValue(sounds, "enabled", true); + Map entries = new EnumMap<>(SoundKey.class); + + for (SoundKey key : SoundKey.values()) { + Map entry = optionalSection(sounds, key.configKey()); + entries.put(key, new SoundEntry(booleanValue(entry, "enabled", true), stringValue(entry, "sound", "UI_BUTTON_CLICK"), floatValue(entry, "volume", 0.7F), floatValue(entry, "pitch", 1.0F))); + } + + return new SoundConfig(enabled, entries); + } + + private static @NotNull Map section(@NotNull Map root, @NotNull String key) { + Object value = root.get(key); + if (!(value instanceof Map map)) { + throw new ConfigException("sounds.yml section '" + key + "' must be an object."); + } + + return stringKeyMap(map); + } + + private static @NotNull Map optionalSection(@NotNull Map root, @NotNull String key) { + Object value = root.get(key); + if (value == null) { + return Map.of(); + } + + if (!(value instanceof Map map)) { + throw new ConfigException("sounds.yml sounds." + key + " must be an object."); + } + + return stringKeyMap(map); + } + + private static boolean booleanValue(@NotNull Map map, @NotNull String key, boolean fallback) { + Object value = map.get(key); + if (value == null) { + return fallback; + } + + if (value instanceof Boolean bool) { + return bool; + } + + return Boolean.parseBoolean(String.valueOf(value)); + } + + private static @NotNull String stringValue(@NotNull Map map, @NotNull String key, @NotNull String fallback) { + Object value = map.get(key); + if (value == null) { + return fallback; + } + + return String.valueOf(value).trim(); + } + + private static float floatValue(@NotNull Map map, @NotNull String key, float fallback) { + Object value = map.get(key); + if (value == null) { + return fallback; + } + + if (value instanceof Number number) { + return number.floatValue(); + } + + try { + return Float.parseFloat(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw new ConfigException("sounds.yml " + key + " must be a number.", exception); + } + } + + private static @NotNull Map stringKeyMap(@NotNull Map source) { + Map result = new LinkedHashMap<>(); + + for (Map.Entry entry : source.entrySet()) { + result.put(String.valueOf(entry.getKey()).trim().toLowerCase(Locale.ROOT), entry.getValue()); + } + + return result; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigLoader.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigLoader.java new file mode 100644 index 0000000..fe76215 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigLoader.java @@ -0,0 +1,183 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.config.ConfigException; +import org.jetbrains.annotations.NotNull; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class SoundConfigLoader { + + private static final String FILE_NAME = "sounds.yml"; + + private final HeadDBPlugin plugin; + private final Path dataDirectory; + + public SoundConfigLoader(@NotNull HeadDBPlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.dataDirectory = plugin.getDataFolder().toPath(); + } + + public @NotNull SoundConfig load() { + Path soundPath = dataDirectory.resolve(FILE_NAME); + + try { + Files.createDirectories(dataDirectory); + + Map defaults = loadBundledDefaults(); + Map current; + + if (Files.notExists(soundPath)) { + current = deepCopyMap(defaults); + saveMap(soundPath, current); + } else { + current = loadMap(soundPath); + } + + boolean changed = mergeMissing(current, defaults); + SoundConfig config = SoundConfig.fromMap(current); + + if (changed) { + saveMap(soundPath, current); + } + + return config; + } catch (Exception exception) { + throw new ConfigException("Failed to load HeadDB sound config from " + soundPath, exception); + } + } + + private @NotNull Map loadBundledDefaults() throws IOException { + try (InputStream input = plugin.getResource(FILE_NAME)) { + if (input == null) { + throw new ConfigException("Bundled " + FILE_NAME + " resource is missing."); + } + + try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + Object loaded = new Yaml().load(reader); + + if (!(loaded instanceof Map map)) { + throw new ConfigException("Bundled " + FILE_NAME + " root must be a YAML object."); + } + + return stringKeyMap(map); + } + } + } + + private static @NotNull Map loadMap(@NotNull Path path) throws IOException { + if (Files.notExists(path)) { + return new LinkedHashMap<>(); + } + + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + Object loaded = new Yaml().load(reader); + + if (loaded == null) { + return new LinkedHashMap<>(); + } + + if (!(loaded instanceof Map map)) { + throw new ConfigException(path.getFileName() + " root must be a YAML object."); + } + + return stringKeyMap(map); + } + } + + private static void saveMap(@NotNull Path path, @NotNull Map map) throws IOException { + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + options.setIndent(2); + options.setWidth(120); + options.setSplitLines(false); + + Yaml yaml = new Yaml(options); + + try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + yaml.dump(map, writer); + } + } + + private static boolean mergeMissing(@NotNull Map target, @NotNull Map defaults) { + boolean changed = false; + + for (Map.Entry entry : defaults.entrySet()) { + String key = entry.getKey(); + Object defaultValue = entry.getValue(); + + if (!target.containsKey(key) || target.get(key) == null) { + target.put(key, deepCopy(defaultValue)); + changed = true; + continue; + } + + Object currentValue = target.get(key); + + if (currentValue instanceof Map rawCurrent && defaultValue instanceof Map rawDefault) { + Map current = stringKeyMap(rawCurrent); + Map defaultMap = stringKeyMap(rawDefault); + + if (mergeMissing(current, defaultMap)) { + target.put(key, current); + changed = true; + } + } + } + + return changed; + } + + private static @NotNull Map stringKeyMap(@NotNull Map source) { + Map result = new LinkedHashMap<>(); + + for (Map.Entry entry : source.entrySet()) { + result.put(String.valueOf(entry.getKey()), deepCopy(entry.getValue())); + } + + return result; + } + + private static Object deepCopy(Object value) { + if (value instanceof Map map) { + return deepCopyMap(stringKeyMap(map)); + } + + if (value instanceof List list) { + List result = new ArrayList<>(); + + for (Object entry : list) { + result.add(deepCopy(entry)); + } + + return result; + } + + return value; + } + + private static @NotNull Map deepCopyMap(@NotNull Map source) { + Map result = new LinkedHashMap<>(); + + for (Map.Entry entry : source.entrySet()) { + result.put(entry.getKey(), deepCopy(entry.getValue())); + } + + return result; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundEntry.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundEntry.java new file mode 100644 index 0000000..75eee97 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundEntry.java @@ -0,0 +1,30 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public record SoundEntry(boolean enabled, @NotNull String sound, float volume, float pitch) { + + public SoundEntry { + Objects.requireNonNull(sound, "sound"); + volume = clamp(volume, 0.0F, 10.0F); + pitch = clamp(pitch, 0.0F, 2.0F); + } + + private static float clamp(float value, float min, float max) { + if (Float.isNaN(value) || Float.isInfinite(value)) { + return min; + } + + if (value < min) { + return min; + } + + if (value > max) { + return max; + } + + return value; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java new file mode 100644 index 0000000..f0b367f --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java @@ -0,0 +1,312 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +public enum SoundKey { + + MENU_OPEN("menu-open"), + CLICK("click"), + BACK("back"), + PAGE_NEXT("page-next"), + PAGE_PREVIOUS("page-previous"), + SEARCH("search"), + OPEN_INPUT("open-input"), + SELECTOR_OPEN("selector-open"), + TAKE_HEAD("take-head"), + GIVE_HEAD("give-head"), + PLAYER_HEAD("player-head"), + FAVORITE_ADD("favorite-add"), + FAVORITE_REMOVE("favorite-remove"), + HIDE_HEAD("hide-head"), + SHOW_HEAD("show-head"), + TOGGLE("toggle"), + ADMIN_MODE_ON("admin-mode-on"), + ADMIN_MODE_OFF("admin-mode-off"), + SETTINGS("settings"), + LANGUAGE("language"), + SAVE("save"), + SAVE_DRAFT("save-draft"), + PUBLISH("publish"), + DELETE("delete"), + DELETE_CONFIRM("delete-confirm"), + CONFIRM("confirm"), + CANCEL("cancel"), + INVALID("invalid"), + NO_PERMISSION("no-permission"), + VALIDATION_ERROR("validation-error"), + ECONOMY_PURCHASE("economy-purchase"), + ECONOMY_INSUFFICIENT_FUNDS("economy-insufficient-funds"), + REFRESH("refresh"), + RELOAD("reload"), + VERIFY("verify"), + DEBUG("debug"), + CUSTOM_MANAGEMENT("custom-management"), + CATEGORY_CREATE("category-create"), + CATEGORY_UPDATE("category-update"), + CATEGORY_DELETE("category-delete"), + TAG_CREATE("tag-create"), + TAG_UPDATE("tag-update"), + TAG_DELETE("tag-delete"), + COLLECTION_CREATE("collection-create"), + COLLECTION_UPDATE("collection-update"), + COLLECTION_DELETE("collection-delete"), + PRICE_SUCCESS("price-success"), + PRICE_FAILURE("price-failure"); + + private final String configKey; + + SoundKey(@NotNull String configKey) { + this.configKey = Objects.requireNonNull(configKey, "configKey"); + } + + public @NotNull String configKey() { + return configKey; + } + + public static @NotNull Optional fromConfigKey(@NotNull String key) { + Objects.requireNonNull(key, "key"); + String normalized = normalize(key); + + for (SoundKey soundKey : values()) { + if (soundKey.configKey.equals(normalized)) { + return Optional.of(soundKey); + } + } + + return Optional.empty(); + } + + public static @NotNull SoundKey fromGuiIcon(@NotNull String iconKey) { + Objects.requireNonNull(iconKey, "iconKey"); + String key = normalize(iconKey); + + if (key.equals("back")) { + return BACK; + } + + if (key.equals("previous")) { + return PAGE_PREVIOUS; + } + + if (key.equals("next")) { + return PAGE_NEXT; + } + + if (key.equals("search") || key.endsWith("-search") || key.startsWith("filter-")) { + return SEARCH; + } + + if (key.equals("settings") || key.startsWith("gui-edit-") || key.equals("reset-language")) { + return SETTINGS; + } + + if (key.equals("languages")) { + return LANGUAGE; + } + + if (key.equals("admin-mode-on")) { + return ADMIN_MODE_ON; + } + + if (key.equals("admin-mode-off")) { + return ADMIN_MODE_OFF; + } + + if (key.equals("confirm-yes")) { + return CONFIRM; + } + + if (key.equals("confirm-no") || key.equals("close")) { + return CANCEL; + } + + if (key.equals("no-permission") || key.equals("empty")) { + return NO_PERMISSION; + } + + if (key.equals("create-head-save") || key.equals("category-save-draft")) { + return SAVE_DRAFT; + } + + if (key.equals("taxonomy-save") || key.equals("category-save")) { + return SAVE; + } + + if (key.equals("category-publish") || key.equals("edit-publish")) { + return PUBLISH; + } + + if (key.equals("category-delete") || key.equals("edit-delete")) { + return DELETE; + } + + if (key.equals("category-price") || key.equals("edit-price")) { + return OPEN_INPUT; + } + + if (key.equals("category-add") || key.equals("create-category")) { + return CATEGORY_CREATE; + } + + if (key.equals("category-remove")) { + return CATEGORY_DELETE; + } + + if (key.equals("create-tag")) { + return TAG_CREATE; + } + + if (key.equals("create-collection")) { + return COLLECTION_CREATE; + } + + if (key.equals("show-all")) { + return SHOW_HEAD; + } + + if (key.equals("edit-visibility-visible")) { + return SHOW_HEAD; + } + + if (key.equals("edit-visibility-hidden")) { + return HIDE_HEAD; + } + + if (key.equals("sort-cycle") || key.equals("sort-direction") || key.equals("show-all") || key.equals("taxonomy-filter-all") || key.equals("taxonomy-filter-custom")) { + return TOGGLE; + } + + if (key.equals("refresh")) { + return REFRESH; + } + + if (key.equals("reload")) { + return RELOAD; + } + + if (key.equals("verify")) { + return VERIFY; + } + + if (key.equals("debug")) { + return DEBUG; + } + + if (key.startsWith("category-") || key.startsWith("taxonomy-") || key.equals("custom-heads") || key.equals("hidden-heads")) { + return CUSTOM_MANAGEMENT; + } + + if (key.startsWith("create-head-") || key.startsWith("edit-") || key.startsWith("lore-")) { + return OPEN_INPUT; + } + + return CLICK; + } + + public static @NotNull SoundKey fromGuiAction(@NotNull String action) { + Objects.requireNonNull(action, "action"); + String key = normalize(action); + + if (key.equals("back") || key.equals("back-main") || key.equals("back-edit") || key.equals("selector-back")) { + return BACK; + } + + if (key.equals("previous") || key.equals("selector-previous")) { + return PAGE_PREVIOUS; + } + + if (key.equals("next") || key.equals("selector-next")) { + return PAGE_NEXT; + } + + if (key.equals("search") || key.equals("filter")) { + return SEARCH; + } + + if (key.equals("confirm")) { + return CONFIRM; + } + + if (key.equals("cancel")) { + return CANCEL; + } + + if (key.equals("save")) { + return SAVE; + } + + if (key.equals("save-draft") || key.equals("create")) { + return SAVE_DRAFT; + } + + if (key.equals("publish")) { + return PUBLISH; + } + + if (key.equals("delete")) { + return DELETE; + } + + if (key.equals("price")) { + return OPEN_INPUT; + } + + if (key.equals("use-held")) { + return TAKE_HEAD; + } + + if (key.equals("show_all") || key.equals("show-all")) { + return SHOW_HEAD; + } + + if (key.equals("visibility")) { + return TOGGLE; + } + + if (key.equals("reset") || key.equals("start-fresh") || key.equals("selector-clear")) { + return TOGGLE; + } + + if (key.equals("id") || key.equals("name") || key.equals("description") || key.equals("permission") || key.equals("texture")) { + return OPEN_INPUT; + } + + if (key.equals("material") || key.equals("head-icon") || key.equals("heads") || key.equals("category") || key.equals("tags") || key.equals("collections") || key.equals("type")) { + return SELECTOR_OPEN; + } + + if (key.equals("add")) { + return CATEGORY_UPDATE; + } + + if (key.startsWith("remove:") || key.startsWith("category-remove:")) { + return CATEGORY_DELETE; + } + + if (key.startsWith("category:") || key.startsWith("collection:") || key.startsWith("tag:") || key.startsWith("selector-entry:")) { + return MENU_OPEN; + } + + if (key.startsWith("category-select:") || key.startsWith("tag-toggle:") || key.startsWith("collection-toggle:")) { + return TOGGLE; + } + + if (key.equals("lore-add") || key.startsWith("lore-set:")) { + return OPEN_INPUT; + } + + if (key.equals("lore-clear") || key.equals("lore-reset")) { + return SAVE; + } + + return CLICK; + } + + private static @NotNull String normalize(@NotNull String value) { + return value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/resources/sounds.yml b/headdb-platforms/headdb-paper/src/main/resources/sounds.yml new file mode 100644 index 0000000..8bd4226 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/resources/sounds.yml @@ -0,0 +1,250 @@ +sounds: + enabled: true + + menu-open: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.55 + pitch: 1.25 + click: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.45 + pitch: 1.0 + back: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 0.75 + page-next: + enabled: true + sound: ITEM_BOOK_PAGE_TURN + volume: 0.65 + pitch: 1.15 + page-previous: + enabled: true + sound: ITEM_BOOK_PAGE_TURN + volume: 0.65 + pitch: 0.9 + search: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.55 + pitch: 1.35 + open-input: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.5 + pitch: 1.15 + selector-open: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 1.1 + + take-head: + enabled: true + sound: ENTITY_ITEM_PICKUP + volume: 0.65 + pitch: 1.25 + give-head: + enabled: true + sound: ENTITY_ITEM_PICKUP + volume: 0.65 + pitch: 1.35 + player-head: + enabled: true + sound: ENTITY_ITEM_PICKUP + volume: 0.65 + pitch: 1.45 + favorite-add: + enabled: true + sound: ENTITY_EXPERIENCE_ORB_PICKUP + volume: 0.55 + pitch: 1.35 + favorite-remove: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.45 + pitch: 0.85 + hide-head: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 0.65 + show-head: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 1.25 + toggle: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 1.05 + admin-mode-on: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.6 + pitch: 1.45 + admin-mode-off: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.5 + pitch: 0.85 + settings: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 1.0 + language: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.55 + pitch: 1.2 + + save: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.65 + pitch: 1.2 + save-draft: + enabled: true + sound: ENTITY_EXPERIENCE_ORB_PICKUP + volume: 0.55 + pitch: 1.1 + publish: + enabled: true + sound: ENTITY_PLAYER_LEVELUP + volume: 0.6 + pitch: 1.4 + delete: + enabled: true + sound: ENTITY_ITEM_BREAK + volume: 0.55 + pitch: 0.75 + delete-confirm: + enabled: true + sound: BLOCK_ANVIL_LAND + volume: 0.35 + pitch: 0.8 + confirm: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.55 + pitch: 1.2 + cancel: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.45 + pitch: 0.75 + + invalid: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.65 + pitch: 0.6 + no-permission: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.65 + pitch: 0.5 + validation-error: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.65 + pitch: 0.65 + + economy-purchase: + enabled: true + sound: ENTITY_EXPERIENCE_ORB_PICKUP + volume: 0.6 + pitch: 1.45 + economy-insufficient-funds: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.65 + pitch: 0.55 + + refresh: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.6 + pitch: 1.25 + reload: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.6 + pitch: 1.05 + verify: + enabled: true + sound: ENTITY_EXPERIENCE_ORB_PICKUP + volume: 0.55 + pitch: 1.25 + debug: + enabled: true + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 0.95 + custom-management: + enabled: true + sound: UI_BUTTON_CLICK + volume: 0.5 + pitch: 1.1 + + category-create: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.6 + pitch: 1.15 + category-update: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.55 + pitch: 1.05 + category-delete: + enabled: true + sound: ENTITY_ITEM_BREAK + volume: 0.5 + pitch: 0.75 + tag-create: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.55 + pitch: 1.2 + tag-update: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.5 + pitch: 1.05 + tag-delete: + enabled: true + sound: ENTITY_ITEM_BREAK + volume: 0.45 + pitch: 0.8 + collection-create: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.55 + pitch: 1.25 + collection-update: + enabled: true + sound: BLOCK_NOTE_BLOCK_CHIME + volume: 0.5 + pitch: 1.1 + collection-delete: + enabled: true + sound: ENTITY_ITEM_BREAK + volume: 0.45 + pitch: 0.85 + + price-success: + enabled: true + sound: ENTITY_EXPERIENCE_ORB_PICKUP + volume: 0.55 + pitch: 1.25 + price-failure: + enabled: true + sound: BLOCK_NOTE_BLOCK_BASS + volume: 0.65 + pitch: 0.6 diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigTest.java new file mode 100644 index 0000000..e820463 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/sound/SoundConfigTest.java @@ -0,0 +1,33 @@ +package io.github.silentdevelopment.headdb.paper.sound; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.io.InputStream; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SoundConfigTest { + + @Test + void parsesBundledDefaultsForEverySoundKey() { + InputStream input = SoundConfigTest.class.getClassLoader().getResourceAsStream("sounds.yml"); + assertNotNull(input); + + Object loaded = new Yaml().load(input); + assertTrue(loaded instanceof Map); + + @SuppressWarnings("unchecked") + SoundConfig config = SoundConfig.fromMap((Map) loaded); + assertTrue(config.enabled()); + + for (SoundKey key : SoundKey.values()) { + SoundEntry entry = config.entry(key); + assertTrue(entry.enabled(), key.configKey()); + assertFalse(entry.sound().isBlank(), key.configKey()); + } + } +} From dfbfd9a95d90b09c5e991c7e218108b2bde0705d Mon Sep 17 00:00:00 2001 From: TheSilentPro <46107752+TheSilentPro@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:12:56 +0200 Subject: [PATCH 3/4] fix: replace stale chat prompts --- headdb-platforms/headdb-paper/pom.xml | 2 +- .../headdb/paper/prompt/PromptInputService.java | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/headdb-platforms/headdb-paper/pom.xml b/headdb-platforms/headdb-paper/pom.xml index fbc45d2..3b5bade 100644 --- a/headdb-platforms/headdb-paper/pom.xml +++ b/headdb-platforms/headdb-paper/pom.xml @@ -34,7 +34,7 @@ 3fc62454e3 3505fd1883 d78876b0e2 - 9f15982383 + 22456310ef 403557ff80 3.53.2.0 diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/prompt/PromptInputService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/prompt/PromptInputService.java index 868c3e8..09264e0 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/prompt/PromptInputService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/prompt/PromptInputService.java @@ -8,6 +8,7 @@ import io.github.silentdevelopment.prompts.paper.text.PaperPromptText; import io.github.silentdevelopment.prompts.parser.ParseResult; import io.github.silentdevelopment.prompts.result.PromptResult; +import io.github.silentdevelopment.prompts.result.PromptResultStatus; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; @@ -45,9 +46,9 @@ public void request(@NotNull Player player, @NotNull Component message, @NotNull Objects.requireNonNull(cancel, "cancel"); Component promptMessage = message.append(Component.text(" Type cancel to abort.", NamedTextColor.GRAY)); - Prompt prompt = new DefaultPrompt<>(PaperPromptText.of(promptMessage), raw -> ParseResult.success(raw), timeout, PaperChatTransport.DEFAULT_NAME); + Prompt prompt = new DefaultPrompt<>(PaperPromptText.of(promptMessage), ParseResult::success, timeout, PaperChatTransport.DEFAULT_NAME); - prompts.askAndHandleSync(player, prompt, result -> handle(result, input, cancel)); + prompts.askReplacingAndHandleSync(player, prompt, result -> handle(result, input, cancel)); } public void shutdown() { @@ -59,6 +60,10 @@ private static void handle(@NotNull PromptResult result, @NotNull Consum Objects.requireNonNull(input, "input"); Objects.requireNonNull(cancel, "cancel"); + if (result.status() == PromptResultStatus.SUPERSEDED) { + return; + } + if (!result.successful()) { cancel.run(); return; From bf3f2360819c6b22f8795265fc1ed94786ef6b43 Mon Sep 17 00:00:00 2001 From: TheSilentPro <46107752+TheSilentPro@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:19:07 +0200 Subject: [PATCH 4/4] fix: fix permissions / prompts handling --- .../headdb/paper/gui/settings/SettingsPageFactory.java | 1 + .../silentdevelopment/headdb/paper/permission/Permissions.java | 3 ++- headdb-platforms/headdb-paper/src/main/resources/gui.yml | 3 ++- .../headdb-paper/src/main/resources/paper-plugin.yml | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java index d98c852..00b2cd7 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/settings/SettingsPageFactory.java @@ -67,6 +67,7 @@ public SettingsPageFactory(@NotNull HeadDBPlugin plugin) { } if (languageOnly) { + set(page, reservedSlots, slot("settings.language-only-back", 0), backButton()); set(page, reservedSlots, slot("settings.language-only", 4), languagesButton()); GuiItems.fillEmpty(plugin, page, rows, reservedSlots); return page.build(); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java index 1517860..6ce8557 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java @@ -10,6 +10,7 @@ public final class Permissions { public static final String ADMIN = "headdb.admin"; + public static final String BASIC = "headdb.basic"; public static final String HELP = "headdb.command.help"; public static final String VERSION = "headdb.command.version"; @@ -147,7 +148,7 @@ public static boolean canOpenFor(@NotNull CommandSender sender, @NotNull Player public static boolean canViewAllCategories(@NotNull CommandSender sender) { Objects.requireNonNull(sender, "sender"); - return has(sender, CATEGORY_ALL); + return has(sender, CATEGORY_ALL) || has(sender, BASIC); } public static boolean canViewCategory(@NotNull CommandSender sender, @NotNull String categoryId) { diff --git a/headdb-platforms/headdb-paper/src/main/resources/gui.yml b/headdb-platforms/headdb-paper/src/main/resources/gui.yml index 6cf3a47..ab0cb71 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/gui.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/gui.yml @@ -795,6 +795,8 @@ slots: settings.reload: 42 settings.refresh: 41 settings.back: 45 + settings.language-only-back: 0 + settings.language-only: 4 edit.preview: 13 edit.name: 21 edit.lore: 23 @@ -846,6 +848,5 @@ slots: tags.search: 53 collections.filter: 52 collections.search: 53 - settings.language-only: 4 edit.price: 35 create-category.price: 44 diff --git a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml index 1373e7d..f0562e1 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml @@ -71,6 +71,7 @@ permissions: headdb.collections: true headdb.tags: true headdb.head.take: true + headdb.category.*: true headdb.open: description: Allows opening the main HeadDB menu.