diff --git a/FORCE_COMMANDS.md b/FORCE_COMMANDS.md deleted file mode 100644 index 67a34b7..0000000 --- a/FORCE_COMMANDS.md +++ /dev/null @@ -1,96 +0,0 @@ -# Force Commands for Fusion - -This document describes the administrator force commands implemented to support issue #58. - -## Overview - -Force commands are admin-only commands that bypass normal profession restrictions and requirements. They are designed to help administrators manage player professions programmatically, especially for NPC interactions and server automation. - -## Permission - -All force commands require the `fusion.admin.force` permission. - -## Commands - -### `/fusion forcejoin ` -Forces a player to join a profession without checking requirements or costs. - -**Usage:** -- `player`: Target player name (must be online) -- `profession`: Name of the profession to join - -**Example:** -``` -/fusion forcejoin Steve blacksmith -``` - -### `/fusion forceleave ` -Forces a player to leave a profession without confirmation prompts. - -**Usage:** -- `player`: Target player name (must be online) -- `profession`: Name of the profession to leave - -**Example:** -``` -/fusion forceleave Steve blacksmith -``` - -### `/fusion forcestats ` -Shows profession statistics for any player. - -**Usage:** -- `player`: Target player name (must be online) - -**Example:** -``` -/fusion forcestats Steve -``` - -### `/fusion forcemaster ` -Forces a player to master a profession without level or fee requirements. - -**Usage:** -- `player`: Target player name (must be online) -- `profession`: Name of the profession to master - -**Example:** -``` -/fusion forcemaster Steve blacksmith -``` - -### `/fusion forceshow ` -Forces the ingredient usage GUI to open for a player based on their held item. - -**Usage:** -- `player`: Target player name (must be online) - -**Example:** -``` -/fusion forceshow Steve -``` - -## Tab Completion - -All force commands support tab completion for: -- Command names when typing the first argument -- Online player names for the player argument -- Available profession names for profession arguments - -## Error Handling - -The commands include comprehensive error handling for: -- Missing permissions -- Invalid syntax/argument count -- Player not found/offline -- Invalid profession names -- Player already has/doesn't have profession -- Player already mastered profession -- No item in hand (for forceshow) - -## Implementation Notes - -- Commands bypass all normal restrictions including costs, requirements, and confirmations -- Uses the same underlying API as regular commands but with forced parameters -- Maintains consistency with existing command patterns and error messages -- Includes proper permission checks to prevent unauthorized usage \ No newline at end of file diff --git a/pom.xml b/pom.xml index 05210d8..008863c 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,7 @@ studio.magemonkey sapphire 1.0.1-R0.2-SNAPSHOT + provided studio.magemonkey diff --git a/src/main/java/studio/magemonkey/fusion/Fusion.java b/src/main/java/studio/magemonkey/fusion/Fusion.java index 12a47b5..6572bad 100644 --- a/src/main/java/studio/magemonkey/fusion/Fusion.java +++ b/src/main/java/studio/magemonkey/fusion/Fusion.java @@ -28,6 +28,7 @@ import studio.magemonkey.fusion.data.player.PlayerLoader; import studio.magemonkey.fusion.data.recipes.*; import studio.magemonkey.fusion.gui.BrowseGUI; +import studio.magemonkey.fusion.gui.recipe.RecipeGuiEventRouter; import studio.magemonkey.fusion.util.ExperienceManager; import studio.magemonkey.fusion.util.LevelFunction; @@ -127,6 +128,7 @@ public void onEnable() { this.getCommand("craft").setExecutor(new Commands()); this.getCommand("fusion-editor").setExecutor(new FusionEditorCommand()); getServer().getPluginManager().registerEvents(this, this); + Bukkit.getPluginManager().registerEvents(new RecipeGuiEventRouter(), this); runQueueTask(); if (hookManager.isHooked(HookType.PlaceholderAPI)) { diff --git a/src/main/java/studio/magemonkey/fusion/api/FusionAPI.java b/src/main/java/studio/magemonkey/fusion/api/FusionAPI.java index 8578e92..40c328b 100644 --- a/src/main/java/studio/magemonkey/fusion/api/FusionAPI.java +++ b/src/main/java/studio/magemonkey/fusion/api/FusionAPI.java @@ -10,11 +10,8 @@ public class FusionAPI { @Getter private static final JavaPlugin instance = Fusion.getInstance(); - @Getter private static ProfessionManager professionManager; - @Getter private static PlayerManager playerManager; - @Getter private static EventServices eventServices; public static void init() { @@ -24,4 +21,18 @@ public static void init() { FusionAPI.getInstance().getLogger().info("FusionAPI has been initialized."); } + public static ProfessionManager getProfessionManager() { + if (professionManager == null) { + professionManager = new ProfessionManager(); + } + return professionManager; + } + + public static PlayerManager getPlayerManager() { + return playerManager != null ? playerManager : new PlayerManager(); + } + + public static EventServices getEventServices() { + return eventServices != null ? eventServices : new EventServices(); + } } diff --git a/src/main/java/studio/magemonkey/fusion/api/events/services/QueueService.java b/src/main/java/studio/magemonkey/fusion/api/events/services/QueueService.java index f76a24b..7738217 100644 --- a/src/main/java/studio/magemonkey/fusion/api/events/services/QueueService.java +++ b/src/main/java/studio/magemonkey/fusion/api/events/services/QueueService.java @@ -5,8 +5,6 @@ import org.bukkit.inventory.ItemStack; import studio.magemonkey.codex.CodexEngine; import studio.magemonkey.codex.api.DelayedCommand; -import studio.magemonkey.codex.api.items.exception.MissingItemException; -import studio.magemonkey.codex.api.items.exception.MissingProviderException; import studio.magemonkey.codex.util.messages.MessageData; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.api.FusionAPI; @@ -20,7 +18,10 @@ import studio.magemonkey.fusion.data.recipes.RecipeItem; import studio.magemonkey.fusion.util.PlayerUtil; -import java.util.*; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; public class QueueService { diff --git a/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java index 938f177..c956f41 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java @@ -1,8 +1,6 @@ package studio.magemonkey.fusion.cfg; import net.kyori.adventure.text.Component; -import org.bukkit.enchantments.Enchantment; -import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; import studio.magemonkey.codex.compat.VersionManager; import studio.magemonkey.codex.util.messages.MessageUtil; @@ -10,8 +8,6 @@ import studio.magemonkey.fusion.data.recipes.RecipeItem; import studio.magemonkey.fusion.util.ChatUT; -import java.util.*; - public class CraftingRequirementsCfg { private static YamlParser config; diff --git a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java index f7195ad..aa7f17c 100644 --- a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java +++ b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java @@ -22,6 +22,7 @@ import studio.magemonkey.fusion.data.recipes.RecipeItem; import studio.magemonkey.fusion.gui.BrowseGUI; import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; +import studio.magemonkey.fusion.gui.RecipeGui; import studio.magemonkey.fusion.gui.show.ShowRecipesGui; import studio.magemonkey.fusion.util.Utils; @@ -74,9 +75,9 @@ public static void useProfession(CommandSender sender, String[] args) { openGui(target, eq, category); CodexEngine.get().getMessageUtil().sendMessage("fusion.useConfirmOther", sender, - new MessageData("craftingInventory", eq), - new MessageData("sender", sender), - new MessageData("target", target)); + new MessageData("craftingInventory", eq.getProfession()), + new MessageData("sender", sender.getName()), + new MessageData("target", target.getName())); } else { if (sender instanceof Player player) { if (!Utils.hasCraftingUsePermission(sender, eq.getProfession())) { @@ -315,6 +316,8 @@ public static void reloadPlugin(CommandSender sender) { Fusion.getInstance().closeAll(); Fusion.getInstance().reloadConfig(); Fusion.getInstance().reloadLang(); + ProfessionGuiRegistry.clearLatestRecipeGui(); + RecipeGui.resetRecipeHashes(); CodexEngine.get() .getMessageUtil() .sendMessage("fusion.reload", sender, new MessageData("sender", sender)); diff --git a/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java b/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java index 3352eb9..80c97e9 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java @@ -12,7 +12,6 @@ import studio.magemonkey.fusion.data.queue.QueueItem; import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.data.recipes.Recipe; -import studio.magemonkey.fusion.gui.RecipeGui; import java.util.Collection; import java.util.Map; @@ -29,8 +28,6 @@ public class FusionPlayer { private Map cachedQueues = new TreeMap<>(); private Map cachedRecipeLimits = new TreeMap<>(); - private final Map cachedGuis = new TreeMap<>(); - @Getter @Setter private boolean autoCrafting; @@ -50,18 +47,11 @@ public Player getPlayer() { } public CraftingQueue getQueue(String profession, Category category) { - if (!cachedQueues.containsKey(profession)) { - cachedQueues.put(profession, new CraftingQueue(getPlayer(), profession, category)); - } - return cachedQueues.get(profession); - } - - public void cacheGui(String id, RecipeGui gui) { - if (cachedGuis.containsKey(id)) { - cachedGuis.get(id).open(getPlayer()); - return; + if (!cachedQueues.containsKey(profession + "." + category.getName())) { + cachedQueues.put(profession + "." + category.getName(), new CraftingQueue(getPlayer(), profession, category)); + Bukkit.getConsoleSender().sendMessage("Created new crafting queue for profession " + profession + " and category " + category.getName() + " for player " + getPlayer().getName()); } - cachedGuis.put(id, gui); + return cachedQueues.get(profession + "." + category.getName()); } public PlayerRecipeLimit getRecipeLimit(Recipe recipe) { @@ -359,9 +349,9 @@ public void save() { } for (CraftingQueue queue : cachedQueues.values()) { SQLManager.queues().saveCraftingQueue(queue); + Bukkit.getConsoleSender().sendMessage("Saved queue for profession " + queue.getProfession() + " and category " + queue.getCategory().getName()); } SQLManager.recipeLimits().saveRecipeLimits(uuid, cachedRecipeLimits); - cachedGuis.clear(); cachedQueues.clear(); cachedRecipeLimits.clear(); } diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/CalculatedProfession.java b/src/main/java/studio/magemonkey/fusion/data/professions/CalculatedProfession.java index be5ea15..dfc5df7 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/CalculatedProfession.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/CalculatedProfession.java @@ -9,7 +9,10 @@ import studio.magemonkey.codex.CodexEngine; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.CraftingRequirementsCfg; -import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.data.recipes.CalculatedRecipe; +import studio.magemonkey.fusion.data.recipes.CraftingTable; +import studio.magemonkey.fusion.data.recipes.Recipe; +import studio.magemonkey.fusion.data.recipes.RecipeItem; import studio.magemonkey.fusion.util.ExperienceManager; import studio.magemonkey.fusion.util.InvalidPatternItemException; diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionSettings.java b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionSettings.java index f4431b1..d190baa 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionSettings.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionSettings.java @@ -2,7 +2,6 @@ import lombok.Getter; import lombok.Setter; -import org.bukkit.Bukkit; import org.bukkit.Color; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.serialization.ConfigurationSerializable; diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/pattern/InventoryPattern.java b/src/main/java/studio/magemonkey/fusion/data/professions/pattern/InventoryPattern.java index d0406d0..9b537ad 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/pattern/InventoryPattern.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/pattern/InventoryPattern.java @@ -11,6 +11,7 @@ import studio.magemonkey.codex.legacy.item.ItemBuilder; import studio.magemonkey.codex.util.DeserializationWorker; import studio.magemonkey.codex.util.SerializationBuilder; +import studio.magemonkey.fusion.data.recipes.RecipeItem; import java.util.AbstractMap.SimpleEntry; import java.util.*; @@ -43,14 +44,14 @@ public InventoryPattern(Map map) { continue; Map section = itemsTemp.getSection(entry); - this.items.put(entry.charAt(0), new ItemBuilder(section).build()); + this.items.put(entry.charAt(0), RecipeItem.fromConfig(section).getItemStack()); if (section.containsKey("closeonclick") && (boolean) section.get("closeonclick")) { closeOnClickSlots.add(entry.charAt(0)); } } if (dw.getSection("items.queue-items.-") != null) - this.items.put('-', new ItemBuilder(dw.getSection("items.queue-items.-")).build()); + this.items.put('-', RecipeItem.fromConfig(dw.getSection("items.queue-items.-")).getItemStack()); final DeserializationWorker commandsTemp = DeserializationWorker.start(dw.getSection("commands", new HashMap<>(2))); diff --git a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java index eccda48..6ebbc9d 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java @@ -9,6 +9,7 @@ import studio.magemonkey.codex.util.messages.MessageData; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.api.FusionAPI; +import studio.magemonkey.fusion.cfg.Cfg; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.cfg.sql.SQLManager; import studio.magemonkey.fusion.data.player.PlayerLoader; @@ -39,9 +40,50 @@ public CraftingQueue(Player player, String profession, Category category) { this.profession = profession; this.category = category; this.queuedItems = new HashMap<>(20); - queue.addAll(SQLManager.queues().getQueueItems(player.getUniqueId(), profession, category)); - queue.forEach(entry -> entry.setCraftinQueue(this)); + // Load items from the database + List loaded = SQLManager.queues().getQueueItems(player.getUniqueId(), profession, category); + queue.addAll(loaded); + + /* + * If offline progression is enabled, distribute the offline time across the + * queue sequentially. All items are saved with the same timestamp when + * saved, so use the first item's timestamp to calculate the offline duration. + */ + if (Cfg.updateQueueOffline && !queue.isEmpty()) { + long now = System.currentTimeMillis(); + // find the first unfinished item + QueueItem current = queue.stream() + .filter(item -> !item.isDone()) + .findFirst() + .orElse(null); + if (current != null) { + int offlineSeconds = (int) ((now - current.getTimestamp()) / 1000L); + // apply offline progress sequentially + for (QueueItem item : queue) { + if (offlineSeconds <= 0) { + break; + } + if (item.isDone()) { + continue; + } + int remaining = item.getRecipe().getCraftingTime() - item.getSavedSeconds(); + int apply = Math.min(offlineSeconds, remaining); + item.progressOffline(apply); + offlineSeconds -= apply; + } + } + // normalize timestamps after applying offline progress + queue.forEach(item -> item.setTimestamp(now)); + } + + // Assign the queue and update the icons + queue.forEach(entry -> { + entry.setCraftinQueue(this); + entry.updateIcon(); + }); + + // Start the queue update task queueTask = new BukkitRunnable() { @Override public void run() { diff --git a/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java b/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java index 086b164..ba3c94f 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; +import lombok.Setter; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import studio.magemonkey.fusion.cfg.Cfg; @@ -19,6 +20,7 @@ public class QueueItem { private Category category; private @NonNull Recipe recipe; private ItemStack icon; + @Setter private long timestamp; private boolean done; private int savedSeconds; @@ -41,18 +43,14 @@ public QueueItem(int id, this.recipe = recipe; this.timestamp = timestamp; this.savedSeconds = savedSeconds; - this.visualRemainingItemTime = (recipe.getCraftingTime() - savedSeconds); - // If the queue item shall not be working when player is offline, just instantly override the timestamp - if (Cfg.updateQueueOffline) { - int diff = (int) ((System.currentTimeMillis() - timestamp) / 1000); - if (diff + savedSeconds > recipe.getCraftingTime()) { - this.savedSeconds = recipe.getCraftingTime(); - this.done = true; - } else { - this.savedSeconds += diff; - } - } - this.timestamp = System.currentTimeMillis(); + this.visualRemainingItemTime = recipe.getCraftingTime() - savedSeconds; + } + + public QueueItem(int id, + String profession, + Category category, + @NotNull Recipe recipe) { + this(id, profession, category, recipe, System.currentTimeMillis(), 0); } public void setCraftinQueue(CraftingQueue craftingQueue) { @@ -66,16 +64,27 @@ public void update() { int reconstructedCooldown = this.visualRemainingItemTime + savedSeconds; if (visualRemainingItemTime == recipe.getCraftingTime() + 1) return; + if (reconstructedCooldown <= recipe.getCraftingTime()) { if (!isRunning) { + // Start the item isRunning = true; + this.timestamp = System.currentTimeMillis(); return; } - this.savedSeconds++; - this.done = savedSeconds >= recipe.getCraftingTime(); - this.icon = ProfessionsCfg.getQueueItem(profession, this); - if (this.savedSeconds > 0) - this.visualRemainingItemTime--; + // Advance progress + savedSeconds++; + this.timestamp = System.currentTimeMillis(); + // Check if finished + if (savedSeconds >= recipe.getCraftingTime()) { + done = true; + // Mark finish time to prevent future overcounting + this.timestamp = System.currentTimeMillis(); + } + icon = ProfessionsCfg.getQueueItem(profession, this); + if (savedSeconds > 0) { + visualRemainingItemTime--; + } } } else { this.icon = ProfessionsCfg.getQueueItem(profession, this); @@ -89,4 +98,21 @@ public void updateIcon() { public String getRecipePath() { return recipe.getRecipePath(); } + + public void progressOffline(int offlineSeconds) { + if (done || offlineSeconds <= 0) { + return; + } + int remaining = recipe.getCraftingTime() - savedSeconds; + if (offlineSeconds >= remaining) { + // item has finished offline + savedSeconds = recipe.getCraftingTime(); + done = true; + } else { + // item partially progressed offline + savedSeconds += offlineSeconds; + } + // update the remaining time for the UI + visualRemainingItemTime = recipe.getCraftingTime() - savedSeconds; + } } diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java b/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java index 720a364..88d7f8b 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java @@ -4,14 +4,10 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; -import org.apache.commons.lang3.tuple.Pair; import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.EnchantmentStorageMeta; import org.bukkit.inventory.meta.ItemMeta; import studio.magemonkey.codex.CodexEngine; @@ -19,123 +15,159 @@ import studio.magemonkey.fusion.cfg.CraftingRequirementsCfg; import studio.magemonkey.fusion.data.player.PlayerLoader; import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; +import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; import studio.magemonkey.fusion.util.ExperienceManager; import studio.magemonkey.fusion.util.InvalidPatternItemException; import studio.magemonkey.fusion.util.Utils; import java.util.*; +/** + * A “calculated” recipe icon + canCraft flag. We build lore lines (ingredients, money, xp, etc.) + * once per relevant change, and store the final Icon + canCraft in a single object. + */ @Getter public class CalculatedRecipe { private final Recipe recipe; - private final ItemStack icon; - private final boolean canCraft; + private final boolean canCraft; - CalculatedRecipe(Recipe recipe, ItemStack icon, boolean canCraft) { + public CalculatedRecipe(Recipe recipe, ItemStack icon, boolean canCraft) { this.recipe = recipe; this.icon = icon; this.canCraft = canCraft; } + /** + * Builds a CalculatedRecipe for the given Recipe and a snapshot of the player’s inventory counts. + * + * @param recipe The Recipe to evaluate + * @param invCounts A Map of everything in the player’s inventory + * @param player The player who is crafting + * @param craftingTable The CraftingTable (for level checks) + * @return a new CalculatedRecipe containing: + * - recipe (underlying Recipe) + * - icon (cloned ItemStack with assembled lore) + * - canCraft (true if player meets all requirements) + * @throws InvalidPatternItemException if building fails + */ public static CalculatedRecipe create(Recipe recipe, - Collection items, + Map invCounts, Player player, CraftingTable craftingTable) throws InvalidPatternItemException { try { StringBuilder lore = new StringBuilder(512); - // TODO Make sure this icon is always applied. Also on Divinity Item Meta existent - ItemStack iconResult = recipe.getSettings().getRecipeItem().getItemStack(); - List resultLore = iconResult.getItemMeta().getLore(); - - /* - TODO This part is natively provided through the settings section soon - if (!recipe.getSettings().isEnableLore()) { - if ((resultLore != null) && !resultLore.isEmpty()) { - resultLore.forEach((str) -> lore.append(str).append('\n')); - lore.append('\n'); - } - } else if (recipe.getSettings().getLore() != null && !recipe.getSettings().getLore().isEmpty()) { - recipe.getSettings().getLore().forEach((str) -> lore.append(ChatUT.hexString(str)).append('\n')); - lore.append('\n'); - }*/ + // Base icon (without lore) + ItemStack iconResult = recipe.getSettings().getRecipeItem().getItemStack(); + ItemMeta baseMeta = iconResult.getItemMeta(); + List resultLore = (baseMeta == null) ? Collections.emptyList() : baseMeta.getLore(); + + // (Optional custom lore logic omitted) + + // 1) “Requirement” header String requirementLine = CraftingRequirementsCfg.getCraftingRequirementLine("recipes"); - if (!requirementLine.isEmpty()) + if (!requirementLine.isEmpty()) { lore.append(requirementLine).append('\n'); + } boolean canCraft = true; - - String recipePermissionLine; + // 2) Permission (learned) check + String recipePermissionLine = null; if (!Utils.hasCraftingPermission(player, recipe.getName())) { canCraft = false; } - recipePermissionLine = CraftingRequirementsCfg.getLearned("recipes", - Utils.hasCraftingPermission(player, recipe.getName())); + recipePermissionLine = CraftingRequirementsCfg.getLearned( + "recipes", + Utils.hasCraftingPermission(player, recipe.getName()) + ); + // 3) Money cost String moneyLine = null; if (recipe.getConditions().getMoneyCost() != 0) { - if (CodexEngine.get().getVault() == null || !CodexEngine.get() - .getVault() - .canPay(player, recipe.getConditions().getMoneyCost())) { + if (CodexEngine.get().getVault() == null || + !CodexEngine.get().getVault().canPay(player, recipe.getConditions().getMoneyCost())) { canCraft = false; } - moneyLine = CraftingRequirementsCfg.getMoney("recipes", - CodexEngine.get().getVault() == null ? 0 - : CodexEngine.get().getVault().getBalance(player), - recipe.getConditions().getMoneyCost()); + double balance = (CodexEngine.get().getVault() == null) + ? 0.0 + : CodexEngine.get().getVault().getBalance(player); + moneyLine = CraftingRequirementsCfg.getMoney( + "recipes", + (int) balance, + recipe.getConditions().getMoneyCost() + ); } + // 4) XP cost String expLine = null; if (recipe.getConditions().getExpCost() != 0) { - if (ExperienceManager.getTotalExperience(player) < recipe.getConditions().getExpCost()) { + int totalExp = ExperienceManager.getTotalExperience(player); + if (totalExp < recipe.getConditions().getExpCost()) { canCraft = false; } - expLine = CraftingRequirementsCfg.getExp("recipes", - ExperienceManager.getTotalExperience(player), - recipe.getConditions().getExpCost()); + expLine = CraftingRequirementsCfg.getExp( + "recipes", + totalExp, + recipe.getConditions().getExpCost() + ); } + // 5) Profession level String levelsLine = null; if (recipe.getConditions().getProfessionLevel() != 0) { - if (recipe.getTable().getLevelFunction().getLevel(player) < recipe.getConditions() - .getProfessionLevel()) { + int profLevel = recipe.getTable().getLevelFunction().getLevel(player); + if (profLevel < recipe.getConditions().getProfessionLevel()) { canCraft = false; } - levelsLine = CraftingRequirementsCfg.getProfessionLevel("recipes", - recipe.getTable().getLevelFunction().getLevel(player), - recipe.getConditions().getProfessionLevel()); + levelsLine = CraftingRequirementsCfg.getProfessionLevel( + "recipes", + profLevel, + recipe.getConditions().getProfessionLevel() + ); } + // 6) Mastery String masteryLine = null; if (recipe.getConditions().isMastery()) { - if (!PlayerLoader.getPlayer(player).hasMastered(craftingTable.getName())) { + boolean hasMastery = PlayerLoader.getPlayer(player) + .hasMastered(craftingTable.getName()); + if (!hasMastery) { canCraft = false; } - masteryLine = CraftingRequirementsCfg.getMastery("recipes", - PlayerLoader.getPlayer(player).hasMastered(craftingTable.getName()), - recipe.getConditions().isMastery()); + masteryLine = CraftingRequirementsCfg.getMastery( + "recipes", + hasMastery, + recipe.getConditions().isMastery() + ); } + // 7) Crafting limit String limitLine = null; if (recipe.getCraftingLimit() > 0) { PlayerRecipeLimit limit = PlayerLoader.getPlayer(player).getRecipeLimit(recipe); - if (limit.getLimit() > 0) { - if (limit.getCooldownTimestamp() > 0 && !limit.hasCooldown()) { - limit.resetLimit(); - Bukkit.getConsoleSender() - .sendMessage( - "§aResetting limit for " + player.getName() + " on " + recipe.getRecipePath()); - } + if (limit.getLimit() > 0 && + limit.getCooldownTimestamp() > 0 && + !limit.hasCooldown()) { + limit.resetLimit(); + Bukkit.getConsoleSender().sendMessage( + "§aResetting limit for " + player.getName() + " on " + recipe.getRecipePath() + ); } if (limit.getLimit() >= recipe.getCraftingLimit()) { canCraft = false; } - limitLine = CraftingRequirementsCfg.getLimit("recipes", limit.getLimit(), recipe.getCraftingLimit()); + limitLine = CraftingRequirementsCfg.getLimit( + "recipes", + limit.getLimit(), + recipe.getCraftingLimit() + ); } - List> conditionLines = recipe.getConditions().getConditionLines(player); + // 8) Custom condition lines + List> conditionLines = + recipe.getConditions().getConditionLines(player); for (Map.Entry entry : conditionLines) { if (!entry.getKey()) { canCraft = false; @@ -143,115 +175,94 @@ public static CalculatedRecipe create(Recipe recipe, } } - List> eqItems = Recipe.getItems(items); - - - Collection localPattern = new LinkedHashSet<>(recipe.getConditions().getRequiredItems()); - for (Iterator it = localPattern.iterator(); it.hasNext(); ) { - RecipeItem recipeItem = it.next(); - ItemStack recipeItemStack = recipeItem.getItemStack(); - ItemStack recipeItemStackOne = recipeItemStack.clone(); - recipeItemStackOne.setAmount(1); - Pair eqEntry = null; - for (Pair entry : eqItems) { - ItemStack item = entry.getKey().clone(); - if (CalculatedRecipe.isSimilar(recipeItemStackOne, item)) { - eqEntry = entry; - break; - } - } + // + // ─── 9) Ingredient check using invCounts ─── + // + Collection localPattern = new LinkedHashSet<>( + recipe.getConditions().getRequiredItems() + ); + + for (Iterator it = localPattern.iterator(); it.hasNext();) { + RecipeItem required = it.next(); + + // We only compare a single “unit” for matching: + ItemStack single = required.getItemStack().clone(); + single.setAmount(1); - int eqAmount = eqEntry != null ? eqEntry.getValue() : 0; - int patternAmount = recipeItem.getAmount(); - if (eqAmount < patternAmount) { + IngredientFingerprint reqKey = IngredientFingerprint.of(single); + int have = invCounts.getOrDefault(reqKey, 0); + int need = required.getAmount(); + + if (have < need) { canCraft = false; - lore.append(CraftingRequirementsCfg.getIngredientLine("recipes", - recipeItem, - eqAmount, - patternAmount)).append('\n'); + lore.append( + CraftingRequirementsCfg.getIngredientLine("recipes", required, have, need) + ).append('\n'); continue; } - if (eqAmount == patternAmount) { - eqItems.remove(eqEntry); - } - int rest = eqAmount - patternAmount; - if (rest > 0 && eqEntry != null) { - eqItems.add(Pair.of(eqEntry.getKey(), rest)); - } - it.remove(); - lore.append(CraftingRequirementsCfg.getIngredientLine("recipes", recipeItem, eqAmount, patternAmount)) - .append('\n'); - } - String canCraftLine = CraftingRequirementsCfg.getCanCraft(canCraft); + // Subtract used quantity so overlapping items are handled correctly + invCounts.put(reqKey, have - need); - lore.append('\n'); - if (moneyLine != null) { - lore.append(moneyLine).append('\n'); - } - if (levelsLine != null) { - lore.append(levelsLine).append('\n'); - } - if (expLine != null) { - lore.append(expLine).append('\n'); - } - if (masteryLine != null) { - lore.append(masteryLine).append('\n'); - } - if (limitLine != null) { - lore.append(limitLine).append('\n'); + lore.append( + CraftingRequirementsCfg.getIngredientLine("recipes", required, have, need) + ).append('\n'); } + // 10) Append summary lines + String canCraftLine = CraftingRequirementsCfg.getCanCraft(canCraft); + lore.append('\n'); + if (moneyLine != null) lore.append(moneyLine).append('\n'); + if (levelsLine != null) lore.append(levelsLine).append('\n'); + if (expLine != null) lore.append(expLine).append('\n'); + if (masteryLine != null) lore.append(masteryLine).append('\n'); + if (limitLine != null) lore.append(limitLine).append('\n'); if (!conditionLines.isEmpty()) { - for (Map.Entry entry : conditionLines) { - lore.append('\n').append(entry.getValue()); + for (Map.Entry e : conditionLines) { + lore.append('\n').append(e.getValue()); } } - - if (recipePermissionLine != null) { - lore.append('\n').append(recipePermissionLine); - } - + lore.append('\n').append(recipePermissionLine); lore.append('\n').append(canCraftLine); - ItemStack icon = iconResult.clone(); - ItemMeta itemMeta = icon.getItemMeta(); - itemMeta.setLore(Arrays.asList(StringUtils.split(lore.toString(), '\n'))); - icon.setItemMeta(itemMeta); + // Build final icon + lore + ItemStack icon = iconResult.clone(); + ItemMeta im = icon.getItemMeta(); + im.setLore(Arrays.asList(StringUtils.split(lore.toString(), '\n'))); + icon.setItemMeta(im); return new CalculatedRecipe(recipe, icon, canCraft); } catch (Exception e) { - Fusion.getInstance() - .error("The recipe-item seems not to be recognized. Please check your setup on the following recipe '" - + recipe.getName()); - Fusion.getInstance().error("Result: " + recipe.getSettings().getIconNamespace()); - Fusion.getInstance().error("Pattern Items: "); - for (Object patternItem : recipe.getConditions().getRequiredItemNames()) { - Fusion.getInstance().error("- " + patternItem); - } - Fusion.getInstance().error("Error on creating CalculatedRecipe: " + e.getMessage()); - e.printStackTrace(); + Fusion.getInstance().error( + "Error creating CalculatedRecipe for '" + recipe.getName() + "': " + e.getMessage() + ); throw new InvalidPatternItemException(e); } } @Override public boolean equals(Object o) { - if (this == o) { - return true; - } - - if (!(o instanceof CalculatedRecipe that)) { - return false; - } + if (this == o) return true; + if (!(o instanceof CalculatedRecipe that)) return false; + return new EqualsBuilder() + .append(this.recipe, that.recipe) + .append(this.icon, that.icon) + .isEquals(); + } - return new EqualsBuilder().append(this.recipe, that.recipe).append(this.icon, that.icon).isEquals(); + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(this.recipe) + .append(this.icon) + .toHashCode(); } + /** + * Unchanged “isSimilar” from before—compares two ItemStacks in a relaxed manner. + */ public static boolean isSimilar(ItemStack is1, ItemStack is2) { - //More relaxed comparison - if (is1.getType() != is2.getType()) - return false; + if (is1.getType() != is2.getType()) return false; ItemMeta im1 = is1.getItemMeta(); ItemMeta im2 = is2.getItemMeta(); @@ -262,8 +273,7 @@ public static boolean isSimilar(ItemStack is1, ItemStack is2) { if (im1.hasDisplayName()) { String displayName1 = im1.getDisplayName().trim(); String displayName2 = im2.hasDisplayName() ? im2.getDisplayName().trim() : ""; - if (!displayName1.equals(displayName2)) - return false; + if (!displayName1.equals(displayName2)) return false; } else if (!im1.hasDisplayName() && im2.hasDisplayName()) { return false; } @@ -291,65 +301,55 @@ public static boolean isSimilar(ItemStack is1, ItemStack is2) { // Check for enchantments if (im1 instanceof EnchantmentStorageMeta storage1) { - EnchantmentStorageMeta storage2 = (EnchantmentStorageMeta) im2; - Map ench1 = storage1.getStoredEnchants(); - Map ench2 = storage2.getStoredEnchants(); - - if (ench1.size() != ench2.size()) - isValid = false; - for (Map.Entry entry : ench1.entrySet()) { - if (!ench2.containsKey(entry.getKey()) || !ench2.get(entry.getKey()).equals(entry.getValue())) + EnchantmentStorageMeta storage2 = (EnchantmentStorageMeta) im2; + Map ench1 = storage1.getStoredEnchants(); + Map ench2 = storage2.getStoredEnchants(); + + if (ench1.size() != ench2.size()) isValid = false; + for (Map.Entry entry : ench1.entrySet()) { + if (!ench2.containsKey(entry.getKey()) || + !ench2.get(entry.getKey()).equals(entry.getValue())) { isValid = false; + } } } else { if (im1.hasEnchants()) { - Map ench1 = im1.getEnchants(); - Map ench2 = im2.getEnchants(); - if (ench1.size() != ench2.size()) - isValid = false; - for (Map.Entry entry : ench1.entrySet()) { - if (!ench2.containsKey(entry.getKey()) || !ench2.get(entry.getKey()).equals(entry.getValue())) + Map ench1 = im1.getEnchants(); + Map ench2 = im2.getEnchants(); + if (ench1.size() != ench2.size()) isValid = false; + for (Map.Entry entry : ench1.entrySet()) { + if (!ench2.containsKey(entry.getKey()) || + !ench2.get(entry.getKey()).equals(entry.getValue())) { isValid = false; + } } } } + // Check for flags if (!im1.getItemFlags().isEmpty()) { - if (im1.getItemFlags().size() != im2.getItemFlags().size()) - isValid = false; + if (im1.getItemFlags().size() != im2.getItemFlags().size()) isValid = false; for (ItemFlag flag : im1.getItemFlags()) { - if (!im2.getItemFlags().contains(flag)) - isValid = false; + if (!im2.getItemFlags().contains(flag)) isValid = false; } } // Check for custom model data if (im1.hasCustomModelData() && im2.hasCustomModelData()) { - if (im1.getCustomModelData() != im2.getCustomModelData()) - isValid = false; + if (im1.getCustomModelData() != im2.getCustomModelData()) isValid = false; } else if (im1.hasCustomModelData() || im2.hasCustomModelData()) { isValid = false; - } + } + // Check if unbreakable if (im1.isUnbreakable()) { - if (im2.isUnbreakable()) - isValid = false; + if (im2.isUnbreakable()) isValid = false; } + // Check for durability if instanceof Damageable - if (im1 instanceof Damageable dmg && dmg.getDamage() > 0) { - int damage1 = dmg.getDamage(); - int damage2 = im2 instanceof Damageable dmg2 ? dmg2.getDamage() : 0; - if (damage1 != damage2) - isValid = false; - } + // TODO - // If all those checks failed, try to check once more through the native item meta check - // This is useful for custom items like from Divinity, etc. + // Final fallback to Bukkit’s built‐in check return isValid || is1.isSimilar(is2); } - - @Override - public int hashCode() { - return new HashCodeBuilder(17, 37).append(this.recipe).append(this.icon).toHashCode(); - } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java b/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java index 311cfea..4d92521 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java +++ b/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java @@ -19,6 +19,9 @@ public class ProfessionGuiRegistry { private final Map categoryGuis = new TreeMap<>(); private final Map recipeGuis = new TreeMap<>(); + @Getter + public static final Map latestRecipeGui = new TreeMap<>(); + public ProfessionGuiRegistry(String profession) { this.profession = profession; } @@ -29,8 +32,9 @@ public void open(Player player) { categoryGuis.put(player.getUniqueId(), new CategoryGui(player, table)); categoryGuis.get(player.getUniqueId()).open(player); } else { - recipeGuis.put(player.getUniqueId(), - new RecipeGui(player, table, new Category("master", "PAPER", table.getRecipePattern(), 1))); + RecipeGui gui = new RecipeGui(player, table, new Category("master", "PAPER", table.getRecipePattern(), 1)); + + recipeGuis.put(player.getUniqueId(), gui); recipeGuis.get(player.getUniqueId()).open(player); } } @@ -71,4 +75,9 @@ public void closeAll() { toClose.forEach(HumanEntity::closeInventory); } + + public static void clearLatestRecipeGui() { + + latestRecipeGui.clear(); + } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java index 12f1997..e243c29 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java @@ -12,13 +12,12 @@ import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; import org.bukkit.event.Event; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import org.bukkit.event.entity.EntityPickupItemEvent; -import org.bukkit.event.inventory.*; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.player.PlayerDropItemEvent; -import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; @@ -44,13 +43,16 @@ import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.data.recipes.Recipe; import studio.magemonkey.fusion.data.recipes.RecipeItem; +import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; +import studio.magemonkey.fusion.gui.recipe.InventoryFingerprint; +import studio.magemonkey.fusion.gui.recipe.RecipeCacheKey; import studio.magemonkey.fusion.gui.slot.Slot; import studio.magemonkey.fusion.util.ChatUT; import studio.magemonkey.fusion.util.ExperienceManager; -import studio.magemonkey.fusion.util.InvalidPatternItemException; import studio.magemonkey.fusion.util.PlayerUtil; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; @Getter public class RecipeGui implements Listener { @@ -75,6 +77,8 @@ public class RecipeGui implements Listener { private int prevQueuePage; private int nextQueuePage; private CraftingQueue queue; + private int lastQueueSecond = -1; // <<< track last‐seen wall‐clock second + private int lastQueueSize = 0; // <<< track last‐seen queue size /* Manual Crafting Mode */ private BukkitTask craftingTask; @@ -95,6 +99,24 @@ public class RecipeGui implements Listener { private final ArrayList blockedSlots = new ArrayList<>(20); private final ArrayList queuedSlots = new ArrayList<>(20); + // Caches all previously built CalculatedRecipe objects with a size limit: + private static final Map recipeCache = Collections.synchronizedMap( + new LinkedHashMap<>(100, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 100; // Limit cache size to 100 entries + } + } + ); + + // Last‐seen “inventory fingerprint” so we know if we truly need to recalc: + private byte[] lastInventoryHash = new byte[0]; + private int lastSeenLevel = -1; + private double lastSeenMoney = -1.0; + + // Last page counts so we skip redraw unless page or queuePage also changed: + private int lastPageCount = -1, lastQueuePageCount = -1; + public RecipeGui(Player player, CraftingTable table, Category category) { this.player = player; this.table = table; @@ -110,7 +132,7 @@ public RecipeGui(Player player, CraftingTable table, Category category) { } setPattern(); if (Cfg.craftingQueue && pattern != null) { - this.queue = PlayerLoader.getPlayer(player).getQueue(table.getName(), this.category); + this.queue = FusionAPI.getPlayerManager().getPlayer(player).getQueue(table.getName(), this.category); } Fusion.registerListener(this); initialize(); @@ -235,125 +257,217 @@ public void initialize() { public void reloadRecipes() { if (!player.isOnline()) return; + try { - setPattern(); - - /* Default setup */ - ItemStack fill = table.getFillItem(); - Collection allRecipes = new ArrayList<>(category.getRecipes()); - allRecipes.removeIf(r -> r.isHidden(player)); - int pageSize = resultSlots.size(); - int allRecipeCount = allRecipes.size(); - int i = 0; - int page = this.page; - - int fullPages = allRecipeCount / pageSize; - int rest = allRecipeCount % pageSize; + // + // ─── 1) Compute new “fingerprint” of the player’s current inventory + level + money ─── + // + byte[] newHash = InventoryFingerprint.fingerprint(player); + int newLevel = table.getLevelFunction().getLevel(player); + double newMoney = (CodexEngine.get().getVault() == null) + ? 0.0 + : CodexEngine.get().getVault().getBalance(player); + + boolean invChanged = !Arrays.equals(newHash, lastInventoryHash); + boolean levelChanged = (newLevel != lastSeenLevel); + boolean moneyChanged = (newMoney != lastSeenMoney); + + lastInventoryHash = newHash; + lastSeenLevel = newLevel; + lastSeenMoney = newMoney; + + // + // ─── 2) Re-obtain the recipe list & calculate total pages ─── + // + setPattern(); // (exactly as before) + ItemStack fill = table.getFillItem(); + + Collection allRecipesCollection = new ArrayList<>(category.getRecipes()); + allRecipesCollection.removeIf(r -> r.isHidden(player)); + + int pageSize = resultSlots.size(); + int totalItems = allRecipesCollection.size(); + int page = this.page; + + int fullPages = (pageSize == 0) ? 0 : totalItems / pageSize; + int rest = (pageSize == 0) ? 0 : totalItems % pageSize; int pages = (rest == 0) ? fullPages : (fullPages + 1); - if (player.isOnline() && page >= pages) { - if (page > 0) { - this.page = pages - 1; - } - // Add a check to prevent infinite recursion - if (this.page != page) { // Only reload if page has changed + if (page >= pages && pages > 0) { + // Clamp page if out-of-range + this.page = pages - 1; + if (this.page != page) { + // Avoid infinite recursion this.reloadRecipes(); } return; } - Collection playerItems = PlayerUtil.getPlayerItems(this.player); - CalculatedRecipe[] calculatedRecipes = - new CalculatedRecipe[(page < pages) ? pageSize : ((rest == 0) ? pageSize : rest)]; - Recipe[] allRecipesArray = allRecipes.toArray(new Recipe[allRecipeCount]); - - Integer[] slots = resultSlots.toArray(new Integer[0]); - for (Integer slot : slots) { - if (slot != null) this.inventory.setItem(slot, null); + // + // ─── 3) Compute total queue pages (if craftingQueue is enabled) ─── + // + List allQueuedItems = (Cfg.craftingQueue && queue != null) + ? new ArrayList<>(queue.getQueue()) + : Collections.emptyList(); + int queueSize = allQueuedItems.size(); + int queuePageSize = queuedSlots.size(); + int fullQueuePages = (queuePageSize == 0) ? 0 : queueSize / queuePageSize; + int restQueue = (queuePageSize == 0) ? 0 : queueSize % queuePageSize; + int queuePages = (restQueue == 0) ? fullQueuePages : (fullQueuePages + 1); + + if (queuePage >= queuePages && queuePages > 0) { + this.queuePage = queuePages - 1; + this.reloadRecipes(); + return; } - /* Additionally, when crafting_queue: true */ - if (Cfg.craftingQueue) { + // + // ─── 4) Bail out early if nothing changed _and_ no unfinished queued items ─── + // + boolean hasUnfinishedQueue = (Cfg.craftingQueue && queue != null && !queue.getQueuedItems().isEmpty()); + boolean queueSizeChanged = (queueSize != lastQueueSize); // <<< check if queue length changed + lastQueueSize = queueSize; // <<< update lastQueueSize + + if (!invChanged && !levelChanged && !moneyChanged + && lastPageCount == page + && lastQueuePageCount == queuePage + && !hasUnfinishedQueue + && !queueSizeChanged) // <<< also require queue size unchanged + { + return; + } + lastPageCount = page; + lastQueuePageCount = queuePage; + + // + // ─── 5) Build a single Map of the player’s entire inventory ─── + // + Map invCounts = new HashMap<>(); + for (ItemStack is : player.getInventory().getContents()) { + if (is == null || is.getType() == Material.AIR) continue; + IngredientFingerprint fp = IngredientFingerprint.of(is); + invCounts.merge(fp, is.getAmount(), Integer::sum); + } - // Clear all queue slots - Integer[] _queuedSlots = queuedSlots.toArray(new Integer[0]); - for (int slot : _queuedSlots) { - this.inventory.setItem(slot, ProfessionsCfg.getQueueSlot(table.getName())); + // + // ─── 6) Clear out any “result” slots from the previous page ─── + // + Integer[] resultSlotArray = resultSlots.toArray(new Integer[0]); + for (Integer slotIndex : resultSlotArray) { + if (slotIndex != null) { + inventory.setItem(slotIndex, null); + } + } + recipes.clear(); + + // + // ─── 7) Re-populate this page’s recipe icons, using a cache key to avoid repeated recalculation ─── + // + Recipe[] allRecipesArray = allRecipesCollection.toArray(new Recipe[0]); + int startIndex = page * pageSize; + int endIndex = Math.min(startIndex + pageSize, totalItems); + + for (int i = startIndex, idx = 0; i < endIndex; i++, idx++) { + Recipe recipe = allRecipesArray[i]; + int slotIndex = resultSlotArray[idx]; + RecipeCacheKey cacheKey = new RecipeCacheKey( + recipe.getRecipePath(), + newHash, + newLevel, + newMoney + ); + + CalculatedRecipe calc; + if (recipeCache.containsKey(cacheKey)) { + calc = recipeCache.get(cacheKey); + } else { + CalculatedRecipe fresh = CalculatedRecipe.create( + recipe, + new HashMap<>(invCounts), + player, + table + ); + recipeCache.put(cacheKey, fresh); + calc = fresh; } - this.queue.getQueuedItems().clear(); - Collection allQueuedItems = queue.getQueue(); - int queueAllItemsCount = allQueuedItems.size(); - if (!allQueuedItems.isEmpty()) { - int queuePageSize = queuedSlots.size(); - if (queuePageSize > 0) { - int j = 0; - int queuePage = this.queuePage; - - int queueFullPages = queueAllItemsCount / queuePageSize; - int queueRest = queueAllItemsCount % queuePageSize; - int queuePages = (queueRest == 0) ? queueFullPages : (queueFullPages + 1); - if (queuePage >= queuePages) { - if (queuePage > 0) - this.queuePage = queuePages - 1; - this.reloadRecipes(); - return; - } - - QueueItem[] queuedItems = new QueueItem[queuePageSize]; - QueueItem[] allQueueItemsArray = allQueuedItems.toArray(new QueueItem[queueAllItemsCount]); - Integer[] queuedSlots = this.queuedSlots.toArray(new Integer[0]); + recipes.put(slotIndex, calc); + inventory.setItem(slotIndex, calc.getIcon().clone()); + } - for (int k = (queuePage * queuePageSize), e = queuedSlots.length; - (k < allQueueItemsArray.length) && (j < e); - k++, j++) { - QueueItem queueItem = allQueueItemsArray[k]; - int slot = queuedSlots[j]; - this.queue.getQueuedItems().put(slot, queuedItems[j] = queueItem); - this.queue.getQueuedItems().get(slot).updateIcon(); + // + // ─── 8) Fill anything not set yet with the “fill” background ─── + // + for (int k = 0; k < inventory.getSize(); k++) { + ItemStack it = inventory.getItem(k); + if (it == null || it.getType() == Material.AIR) { + inventory.setItem(k, fill.clone()); + } + } - this.inventory.setItem(slot, queuedItems[j].getIcon().clone()); + // + // ─── 9) If crafting-queue mode is enabled, clear + rebuild queued slots + // ─ only when the wall-clock second or queueSize changed ─ + // + if (Cfg.craftingQueue && queue != null) { + int nowSec = (int) (System.currentTimeMillis() / 1000L); + if (nowSec != lastQueueSecond || queueSizeChanged) { // <<< MODIFIED + lastQueueSecond = nowSec; // <<< MODIFIED + + // 9a) Clear all queue slots to the “empty queue” icon + Integer[] queuedIndices = queuedSlots.toArray(new Integer[0]); + for (int qIndex : queuedIndices) { + inventory.setItem(qIndex, ProfessionsCfg.getQueueSlot(table.getName())); + } + this.queue.getQueuedItems().clear(); + + // 9b) Place each queued item onto its slot for the current queuePage + if (!allQueuedItems.isEmpty() && queuePageSize > 0) { + int j = 0; + int qStart = queuePage * queuePageSize; + int qEnd = Math.min(qStart + queuePageSize, queueSize); + QueueItem[] allQueueItemsArray = allQueuedItems.toArray(new QueueItem[0]); + Integer[] qSlots = queuedIndices; + + for (int q = qStart; q < qEnd && j < qSlots.length; q++, j++) { + QueueItem qi = allQueueItemsArray[q]; + int slot = qSlots[j]; + this.queue.getQueuedItems().put(slot, qi); + qi.updateIcon(); + inventory.setItem(slot, qi.getIcon().clone()); } } } + // (Otherwise, same second / queueSize, so skip rebuilding this block.) } + + // + // ─── 10) Finally, update “arrows” / “fill” / etc. exactly as before ─── + // updateBlockedSlots(new MessageData[]{ - new MessageData("level", table.getLevelFunction().getLevel(player)), + new MessageData("level", table.getLevelFunction().getLevel(player)), new MessageData("category", category), - new MessageData("gui", getName()), - new MessageData("player", player.getName()), + new MessageData("gui", getName()), + new MessageData("player", player.getName()), new MessageData("bal", - CodexEngine.get().getVault() == null ? 0 + CodexEngine.get().getVault() == null + ? 0 : CodexEngine.get().getVault().getBalance(player)) }); - for (int k = (page * pageSize), e = Math.min(slots.length, calculatedRecipes.length); - (k < allRecipesArray.length) && (i < e); - k++, i++) { - Recipe recipe = allRecipesArray[k]; - int slot = slots[i]; - try { - CalculatedRecipe calculatedRecipe = CalculatedRecipe.create(recipe, playerItems, this.player, table); - this.recipes.put(slot, calculatedRecipes[i] = calculatedRecipe); - this.inventory.setItem(slot, calculatedRecipe.getIcon().clone()); - } catch (InvalidPatternItemException ignored) { - } - } - - for (int k = 0; k < inventory.getSize(); k++) { - if (inventory.getItem(k) != null && inventory.getItem(k).getType() != Material.AIR) - continue; - inventory.setItem(k, fill); - } this.isLoaded = true; - } catch ( - Exception e) { + } + catch (Exception e) { + // On any exception, clear the inventory and close it to avoid partial states this.inventory.clear(); Bukkit.getScheduler().runTask(Fusion.getInstance(), this.player::closeInventory); - throw new RuntimeException("Exception was thrown when reloading recipes for: " + this.player.getName(), - e); - } finally { - if (Cfg.craftingQueue && !queue.getQueuedItems().isEmpty()) { + throw new RuntimeException( + "Exception was thrown when reloading recipes for: " + this.player.getName(), e + ); + } + finally { + // If queue-mode is on and there are unfinished items, re-schedule another reload in 1 second + if (Cfg.craftingQueue && queue != null && !queue.getQueuedItems().isEmpty()) { boolean requiresUpdate = false; for (Map.Entry entry : queue.getQueuedItems().entrySet()) { if (!entry.getValue().isDone()) { @@ -364,7 +478,7 @@ public void reloadRecipes() { if (requiresUpdate) { Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::reloadRecipes, 20L); } - isLoaded = true; + this.isLoaded = true; } } } @@ -473,7 +587,8 @@ public Slot getSlot(int i) { } public void open(Player player) { - if(!isLoaded) + ProfessionGuiRegistry.getLatestRecipeGui().put(player.getUniqueId(), this); + if (!isLoaded) reloadRecipes(); player.openInventory(inventory); } @@ -523,7 +638,7 @@ private boolean craft(int slot, boolean addToCursor) { return false; } CalculatedRecipe calculatedRecipe = this.recipes.get(slot); - Recipe recipe = calculatedRecipe.getRecipe(); + Recipe recipe = calculatedRecipe.getRecipe(); if (craftingRecipe != null && craftingRecipe.equals(recipe)) { cancel(true); return false; @@ -535,16 +650,16 @@ private boolean craft(int slot, boolean addToCursor) { RecipeItem recipeResult = recipe.getSettings().getRecipeItem(); ItemStack resultItem = recipeResult.getItemStack(); - //Add "Crafted by" + // Add "Crafted by" lore if the player has permission if (player.hasPermission("fusion.craftedby." + recipe.getName())) { ItemMeta meta = resultItem.getItemMeta(); - List lore = (meta != null && meta.hasLore()) ? meta.getLore() : new ArrayList<>(); lore.add(ChatColor.WHITE + " - " + ChatColor.YELLOW + "Crafted by: " + ChatColor.WHITE + player.getName()); meta.setLore(lore); resultItem.setItemMeta(meta); } + // If adding directly to cursor, ensure enough room if (addToCursor) { ItemStack cursor = this.player.getItemOnCursor(); if (resultItem.isSimilar(cursor)) { @@ -556,56 +671,88 @@ private boolean craft(int slot, boolean addToCursor) { } } - Collection itemsToTake = recipe.getItemsToTake(); - Collection taken = new ArrayList<>(itemsToTake.size()); - PlayerInventory inventory = this.player.getInventory(); - - for (Iterator iterator = itemsToTake.iterator(); iterator.hasNext(); ) { - ItemStack toTake = iterator.next(); - for (ItemStack entry : PlayerUtil.getPlayerItems(player)) { - ItemStack item = entry.clone(); - entry = entry.clone(); - item = item.clone(); - entry.setAmount(toTake.getAmount()); + // + // ─── 1) Build a local copy of the ingredient list ─── + // + List requiredItems = new ArrayList<>(recipe.getItemsToTake()); + // Track exactly what we remove, so we can refund on failure + List removedSoFar = new ArrayList<>(); + + PlayerInventory inv = this.player.getInventory(); + boolean missingSomething = false; + + // + // ─── 2) For each required ingredient, manually drain across all matching slots ─── + // + for (ItemStack required : requiredItems) { + int need = required.getAmount(); + ItemStack neededTemplate = required.clone(); // same material+meta + + // Iterate through every inventory slot to match via isSimilar() + for (int slotIndex = 0; slotIndex < inv.getSize() && need > 0; slotIndex++) { + ItemStack slotStack = inv.getItem(slotIndex); + if (slotStack == null || slotStack.getType() == Material.AIR) continue; + + // Use CalculatedRecipe.isSimilar() to match custom NBT/lore + if (!CalculatedRecipe.isSimilar(neededTemplate, slotStack)) { + continue; + } - if (CalculatedRecipe.isSimilar(toTake, item)) { - toTake = entry; - break; + int available = slotStack.getAmount(); + int take = Math.min(available, need); + // Subtract “take” from that slot + slotStack.setAmount(available - take); + if (slotStack.getAmount() <= 0) { + inv.setItem(slotIndex, null); + } else { + inv.setItem(slotIndex, slotStack); } - } - HashMap notRemoved = inventory.removeItem(toTake); - if (notRemoved.isEmpty()) { - taken.add(toTake); - iterator.remove(); - continue; + // Track exactly what we removed + ItemStack actuallyTaken = neededTemplate.clone(); + actuallyTaken.setAmount(take); + removedSoFar.add(actuallyTaken); + + need -= take; } - for (ItemStack itemStack : taken) { - HashMap notAdded = inventory.addItem(itemStack); - if (notAdded.isEmpty()) { - break; - } - for (ItemStack stack : notAdded.values()) { - this.player.getWorld().dropItemNaturally(this.player.getLocation(), stack); + + if (need > 0) { + // Could not find enough of “required” across all slots + missingSomething = true; + + // ─── Roll back everything we already removed ─── + for (ItemStack alreadyRemoved : removedSoFar) { + Map overflow = inv.addItem(alreadyRemoved.clone()); + for (ItemStack drop : overflow.values()) { + this.player.getWorld().dropItemNaturally(this.player.getLocation(), drop); + } } + break; } - break; } - refund.addAll(taken); - - if (!itemsToTake.isEmpty()) { - CodexEngine.get() - .getMessageUtil() + if (missingSomething) { + // At least one ingredient was short → inform player and abort + CodexEngine.get().getMessageUtil() .sendMessage("fusion.error.insufficientItems", player, new MessageData("recipe", recipe)); cancel(true); return false; } + + // All ingredients were successfully removed; add those to refund list + refund.addAll(removedSoFar); + + // + // ─── 3) Proceed with cooldown / boss‐bar / giving the result ─── + // if (!Cfg.craftingQueue) { double modifier = Fusion.getInstance().getPlayerCooldown(player); int cooldown = modifier == 0d ? recipe.getCraftingTime() - : (int) Math.round(recipe.getCraftingTime() - (recipe.getCraftingTime() * modifier)); + : (int) Math.round( + recipe.getCraftingTime() - (recipe.getCraftingTime() * modifier) + ); + showBossBar(this.player, recipe.getSettings().getRecipeItem().getItemStack(), cooldown); if (cooldown != 0) { @@ -617,6 +764,7 @@ private boolean craft(int slot, boolean addToCursor) { craftingRecipe = recipe; craftingTask = Fusion.getInstance().runTaskLater(cooldown, () -> { craftingSuccess = true; + if (recipe.getResults().getCommands().isEmpty()) { if (addToCursor) { ItemStack cursor = this.player.getItemOnCursor(); @@ -637,7 +785,7 @@ private boolean craft(int slot, boolean addToCursor) { } else { boolean fits = calcWillFit(resultItem); if (fits) { - HashMap notAdded = inventory.addItem(resultItem); + HashMap notAdded = inv.addItem(resultItem); if (!notAdded.isEmpty()) { for (ItemStack stack : notAdded.values()) { this.player.getWorld().dropItemNaturally(this.player.getLocation(), stack); @@ -729,7 +877,7 @@ public void run() { }.runTaskTimer(Fusion.getInstance(), 1L, 1L); } - private void cancel(boolean refund) { + private void cancel(boolean refundAll) { if (!Cfg.craftingQueue) { if (craftingTask == null) return; craftingRecipe = null; @@ -745,26 +893,27 @@ private void cancel(boolean refund) { } if (player.getOpenInventory().getCursor() != null - && player.getOpenInventory().getCursor().getType() == Material.BARRIER) + && player.getOpenInventory().getCursor().getType() == Material.BARRIER) { if (previousCursor != null) { player.getOpenInventory().setCursor(previousCursor); previousCursor = null; - } else + } else { player.getOpenInventory().setCursor(new ItemStack(Material.AIR)); + } + } if (craftingTask != null) craftingTask.cancel(); craftingTask = null; - if (!refund || craftingSuccess) + if (!refundAll || craftingSuccess) return; - PlayerInventory inventory = player.getInventory(); Collection notAdded = inventory.addItem(this.refund.toArray(new ItemStack[0])).values(); if (!notAdded.isEmpty()) { for (ItemStack item : notAdded) { - player.getLocation().getWorld().dropItemNaturally(player.getLocation(), item); + player.getLocation().getWorld().dropItem(player.getLocation(), item); } } this.refund.clear(); @@ -788,7 +937,7 @@ public void click(InventoryClickEvent event) { Character c = pattern.getSlot(event.getRawSlot()); executeCommands(c, event.getWhoClicked()); - //Close on click + // Close on click if (pattern.getCloseOnClickSlots().contains(c)) { Bukkit.getScheduler().runTask(Fusion.getInstance(), () -> event.getWhoClicked().closeInventory()); } @@ -818,8 +967,7 @@ public void click(InventoryClickEvent event) { if (slots[event.getRawSlot()].equals(Slot.BASE_RESULT_SLOT)) { event.setCancelled(true); event.setResult(Event.Result.DENY); - Fusion.getInstance().runSync(() -> - { + Fusion.getInstance().runSync(() -> { this.reloadRecipes(); this.craft(event.getRawSlot(), false); this.reloadRecipesTask(); @@ -850,7 +998,6 @@ public void click(InventoryClickEvent event) { return; } if (event.getCursor().getType() != Material.AIR) { - if (Slot.SPECIAL_CRAFTING_SLOT.canHoldItem(event.getCursor()) == null) { event.setResult(Event.Result.DENY); return; @@ -859,15 +1006,16 @@ public void click(InventoryClickEvent event) { this.reloadRecipesTask(); } - private void close(Player p, Inventory inv) { + public void close(Player p, Inventory inv) { if (inv == null) { return; } Inventory pInventory = p.getInventory(); if (inv.equals(this.inventory)) { for (int i = 0; i < this.slots.length; i++) { - if (this.slots[i].equals(Slot.BLOCKED_SLOT) || this.slots[i].equals(Slot.BASE_RESULT_SLOT) - || this.slots[i].equals(Slot.QUEUED_SLOT)) { + if (this.slots[i].equals(Slot.BLOCKED_SLOT) || + this.slots[i].equals(Slot.BASE_RESULT_SLOT) || + this.slots[i].equals(Slot.QUEUED_SLOT)) { continue; } ItemStack it = inv.getItem(i); @@ -884,20 +1032,10 @@ private void close(Player p, Inventory inv) { } } - @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) - public void onClick(InventoryClickEvent event) { - if (event.getInventory() != getInventory()) return; - if (event.getRawSlot() < 0) { - return; - } - click(event); - } - /* Event to prevent the player from dragging items into the crafting slots while doing manual crafting */ - @EventHandler(ignoreCancelled = true) public void onDrag(InventoryDragEvent e) { if (!(e.getWhoClicked() instanceof Player)) { return; @@ -907,13 +1045,14 @@ public void onDrag(InventoryDragEvent e) { e.setCancelled(true); if (e.getRawSlots() .stream() - .anyMatch(i -> (i < this.slots.length) && (!Objects.equals(this.slots[i], - Slot.SPECIAL_CRAFTING_SLOT)))) { + .anyMatch(i -> (i < this.slots.length) && + (!Objects.equals(this.slots[i], Slot.SPECIAL_CRAFTING_SLOT)))) { e.setResult(Event.Result.DENY); return; } - if (e.getNewItems().values().stream().anyMatch(i -> Slot.SPECIAL_CRAFTING_SLOT.canHoldItem(i) == null)) { + if (e.getNewItems().values().stream().anyMatch(i -> + Slot.SPECIAL_CRAFTING_SLOT.canHoldItem(i) == null)) { e.setResult(Event.Result.DENY); } reloadRecipesTask(); @@ -924,7 +1063,6 @@ public void onDrag(InventoryDragEvent e) { Event to prevent the player from dropping items into the crafting slots while doing manual crafting */ - @EventHandler public void drop(PlayerDropItemEvent event) { Player player = event.getPlayer(); if (this.getInventory().getViewers().contains(player) && !Cfg.craftingQueue) { @@ -940,25 +1078,7 @@ public void drop(PlayerDropItemEvent event) { } } - // Events to close the players inventory - @EventHandler(ignoreCancelled = true) - public void onClose(InventoryCloseEvent e) { - if (e.getPlayer() instanceof Player) { - e.getInventory(); - close((Player) e.getPlayer(), e.getInventory()); - } - } - - @EventHandler(ignoreCancelled = true) - public void onClose(EntityPickupItemEvent e) { - if (!(e.getEntity() instanceof Player)) { - return; - } - reloadRecipesTask(); - } - - @EventHandler(ignoreCancelled = true) - public void onExit(PlayerQuitEvent e) { - close(e.getPlayer(), inventory); + public static void resetRecipeHashes() { + recipeCache.clear(); } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java new file mode 100644 index 0000000..d8c0aa7 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java @@ -0,0 +1,98 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.*; + +/** + * Immutable fingerprint for an ItemStack that matches CalculatedRecipe.isSimilar(...) logic. + * + * We compare: + * - Material + * - customModelData (if present) + * - displayName (if present) + * - lore lines (if present) + * - all enchantments (if present) + * - unbreakable flag + * - durability (if Damageable) + */ +public class IngredientFingerprint { + private final Material type; + private final int customModelData; + private final String displayName; + private final List lore; + private final Map enchantments; + private final boolean unbreakable; + private final int durability; + + public IngredientFingerprint(Material type, + int customModelData, + String displayName, + List lore, + Map enchantments, + boolean unbreakable, + int durability) { + this.type = type; + this.customModelData = customModelData; + this.displayName = (displayName == null ? "" : displayName); + this.lore = (lore == null ? Collections.emptyList() : new ArrayList<>(lore)); + this.enchantments = (enchantments == null ? Collections.emptyMap() : new HashMap<>(enchantments)); + this.unbreakable = unbreakable; + this.durability = durability; + } + + /** Build an IngredientFingerprint by examining a live ItemStack. */ + public static IngredientFingerprint of(ItemStack is) { + Material mat = is.getType(); + ItemMeta meta = is.getItemMeta(); + + int cmd = 0; + String name = ""; + List loreList = Collections.emptyList(); + Map enchantsMap = Collections.emptyMap(); + boolean unbreak = false; + int dmg = 0; + + if (meta != null) { + if (meta.hasCustomModelData()) { + cmd = meta.getCustomModelData(); + } + if (meta.hasDisplayName()) { + name = meta.getDisplayName(); + } + if (meta.hasLore()) { + loreList = new ArrayList<>(Objects.requireNonNull(meta.getLore())); + } + Map raw = meta.getEnchants(); + if (!raw.isEmpty()) { + enchantsMap = new HashMap<>(raw); + } + unbreak = meta.isUnbreakable(); + if (meta instanceof org.bukkit.inventory.meta.Damageable dmeta) { + dmg = dmeta.getDamage(); + } + } + + return new IngredientFingerprint(mat, cmd, name, loreList, enchantsMap, unbreak, dmg); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IngredientFingerprint that)) return false; + return customModelData == that.customModelData && + unbreakable == that.unbreakable && + durability == that.durability && + type == that.type && + Objects.equals(displayName, that.displayName) && + Objects.equals(lore, that.lore) && + Objects.equals(enchantments, that.enchantments); + } + + @Override + public int hashCode() { + return Objects.hash(type, customModelData, displayName, lore, enchantments, unbreakable, durability); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/InventoryFingerprint.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/InventoryFingerprint.java new file mode 100644 index 0000000..d2b0688 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/InventoryFingerprint.java @@ -0,0 +1,79 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +/** + * Helper to build a small MD5 fingerprint of an entire PlayerInventory. + * We incorporate: + * - Material ordinal + * - amount + * - customModelData + * - displayName + * - lore lines + * - enchantments + * - unbreakable + * - durability if Damageable + * + * If MD5 is not available, we fall back to a simple int‐hash of slot hashCodes. + */ +public class InventoryFingerprint { + public static byte[] fingerprint(Player p) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + for (ItemStack is : p.getInventory().getContents()) { + if (is == null || is.getType() == Material.AIR) { + md.update((byte) 0); + continue; + } + ItemMeta im = is.getItemMeta(); + // Material + md.update((byte) is.getType().ordinal()); + // Amount (4 bytes) + // displayName + if (im != null && im.hasDisplayName()) { + byte[] nameBytes = im.getDisplayName().getBytes(java.nio.charset.StandardCharsets.UTF_8); + md.update(nameBytes); + } + // lore + if (im != null && im.hasLore()) { + for (String line : Objects.requireNonNull(im.getLore())) { + byte[] lineBytes = line.getBytes(java.nio.charset.StandardCharsets.UTF_8); + md.update(lineBytes); + } + } + // enchantments + if (im != null && im.hasEnchants()) { + im.getEnchants().forEach((ench, lvl) -> { + md.update(ByteBuffer.allocate(4).putInt(ench.getKey().hashCode()).array()); + md.update(ByteBuffer.allocate(4).putInt(lvl).array()); + }); + } + // unbreakable + durability + if (im != null) { + if (im.isUnbreakable()) { + md.update((byte) 1); + } + if (im instanceof org.bukkit.inventory.meta.Damageable dmeta && dmeta.getDamage() > 0) { + md.update(ByteBuffer.allocate(4).putInt(dmeta.getDamage()).array()); + } + } + } + return md.digest(); + } catch (NoSuchAlgorithmException e) { + // Fallback: compute a 4‐byte hash code of all slot hashCodes + int h = 7; + for (ItemStack is : p.getInventory().getContents()) { + h = h * 31 + ((is == null) ? 0 : is.hashCode()); + } + return ByteBuffer.allocate(4).putInt(h).array(); + } + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeCacheKey.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeCacheKey.java new file mode 100644 index 0000000..154d1b9 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeCacheKey.java @@ -0,0 +1,47 @@ +package studio.magemonkey.fusion.gui.recipe; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Key used for caching a CalculatedRecipe. Uniquely identifies a player’s view of one recipe. + */ +public class RecipeCacheKey { + private final String recipeId; + private final byte[] inventoryHash; + private final int playerLevel; + private final double playerMoney; + + // TODO add: + /* + * - Vanilla Exp + * - Conditions.McMMO Map + * - Conditions.Fabled Map + * - Conditions.Aura Map + * - Conditions.ProfessionLevels Map + */ + + public RecipeCacheKey(String recipeId, byte[] inventoryHash, int playerLevel, double playerMoney) { + this.recipeId = recipeId; + this.inventoryHash = Arrays.copyOf(inventoryHash, inventoryHash.length); + this.playerLevel = playerLevel; + this.playerMoney = playerMoney; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RecipeCacheKey that)) return false; + return playerLevel == that.playerLevel && + Double.compare(that.playerMoney, playerMoney) == 0 && + Objects.equals(recipeId, that.recipeId) && + Arrays.equals(inventoryHash, that.inventoryHash); + } + + @Override + public int hashCode() { + int result = Objects.hash(recipeId, playerLevel, playerMoney); + result = 31 * result + Arrays.hashCode(inventoryHash); + return result; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java new file mode 100644 index 0000000..2f9435f --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java @@ -0,0 +1,112 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import studio.magemonkey.fusion.data.player.FusionPlayer; +import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; +import studio.magemonkey.fusion.gui.RecipeGui; + +/** + * Centralized listener for all RecipeGui‐related events. + * For each incoming event, we look up the player’s FusionPlayer and its cachedGuis. + * If an event’s Inventory matches one of the cached RecipeGui inventories, we forward + * to that RecipeGui’s click/drag/close/drop logic. + */ +public class RecipeGuiEventRouter implements Listener { + + /** + * Look up, for a given Player, which RecipeGui (if any) has this exact Inventory open. + * We fetch that player’s FusionPlayer via PlayerLoader.getPlayer(Player). + */ + private RecipeGui findGuiFor(Player player, Inventory inv) { + if(!ProfessionGuiRegistry.getLatestRecipeGui().containsKey(player.getUniqueId())) + return null; + RecipeGui gui = ProfessionGuiRegistry.getLatestRecipeGui().get(player.getUniqueId()); + if (gui.getInventory().equals(inv)) { + return gui; + } + return null; + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player p)) return; + Inventory inv = event.getInventory(); + RecipeGui gui = findGuiFor(p, inv); + if (gui == null) return; + + // Only forward if the clicked inventory is *exactly* the GUI’s inventory + if (!inv.equals(gui.getInventory())) return; + if (event.getRawSlot() < 0) return; + + // Delegate to RecipeGui.click(...) + gui.click(event); + } + + @EventHandler(ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player p)) return; + Inventory inv = event.getInventory(); + RecipeGui gui = findGuiFor(p, inv); + if (gui == null) return; + + // Only route if the drag is happening inside this GUI’s inventory + if (!inv.equals(gui.getInventory())) return; + + // Delegate to RecipeGui’s drag logic + gui.onDrag(event); + } + + @EventHandler(ignoreCancelled = true) + public void onInventoryClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player p)) return; + Inventory inv = event.getInventory(); + RecipeGui gui = findGuiFor(p, inv); + if (gui == null) return; + + // If the player closes this GUI, perform cleanup + gui.close(p, inv); + } + + @EventHandler(ignoreCancelled = true) + public void onPlayerDrop(PlayerDropItemEvent event) { + Player p = event.getPlayer(); + RecipeGui gui = findGuiFor(p, p.getOpenInventory().getTopInventory()); + if (gui == null) return; + + // If the player drops an item while their RecipeGui is open, route to its drop logic + gui.drop(event); + } + + @EventHandler(ignoreCancelled = true) + public void onItemPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player p)) return; + RecipeGui gui = findGuiFor(p, p.getOpenInventory().getTopInventory()); + if (gui == null) return; + + // If the player picks up anything while the GUI is open, trigger a reload + gui.reloadRecipesTask(); + } + + @EventHandler(ignoreCancelled = true) + public void onPlayerQuit(PlayerQuitEvent event) { + Player p = event.getPlayer(); + FusionPlayer fp = PlayerLoader.getPlayer(p); + if (fp == null) return; + + // On quit, close and remove *all* open RecipeGuis for that player + RecipeGui gui = ProfessionGuiRegistry.getLatestRecipeGui().get(p.getUniqueId()); + if(gui == null) return; + gui.close(p, gui.getInventory()); + } +}