From e7a8001911a3b61afa308a23a735f063f63eb00d Mon Sep 17 00:00:00 2001 From: MaksyKun <77341370+MaksyKun@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:25:08 +0200 Subject: [PATCH 1/4] add instant collect feature to queues when cooldown is <= 0 --- doc/features/Features.md | 10 +++++++++- src/main/java/studio/magemonkey/fusion/cfg/Cfg.java | 3 +++ .../magemonkey/fusion/commands/CommandMechanics.java | 2 +- .../magemonkey/fusion/data/queue/CraftingQueue.java | 5 +++++ .../studio/magemonkey/fusion/data/queue/QueueItem.java | 5 +++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/doc/features/Features.md b/doc/features/Features.md index 5d52300..1daf3f9 100644 --- a/doc/features/Features.md +++ b/doc/features/Features.md @@ -1,6 +1,14 @@ # Handling Professions ## General Configuration +The queue supports instant collection for recipes that do not have a crafting delay: + +```yaml +instant_collect: false +``` + +Set `instant_collect` to `true` to collect recipes with a `craftingTime` less than or equal to zero immediately from the recipe GUI. + ## Browsing ### Usage You open the browse gui with the command `/craft browse`. @@ -808,4 +816,4 @@ recipes: ### Additionals - Setting the permission `fusion.browse` to false in your permission handler disabled the browse command for the player. If you dont set it at all, its natively active. You can also look for further permissions under the [Permissions](Permissions) page. -- Make sure to visit [Customizing Sections](Customizations) for further knowledge about the ItemBuilder, Cost-Section and Condition-Section of professions and even recipes! \ No newline at end of file +- Make sure to visit [Customizing Sections](Customizations) for further knowledge about the ItemBuilder, Cost-Section and Condition-Section of professions and even recipes! diff --git a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java index e76f3e4..c333f64 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java @@ -27,6 +27,7 @@ public final class Cfg { public static long dataSaveInterval = 12000; public static double forgetPenalty = 0.2d; public static boolean craftingQueue = true; + public static boolean instantCollect = false; public static int finishedMessageInterval = 300; public static boolean updateQueueOffline = true; public static boolean showRequirementsOnBrowse = true; @@ -86,6 +87,7 @@ private static void addDefs(FileConfiguration cfg) { if (!cfg.isSet("data_save_interval")) cfg.set("data_save_interval", dataSaveInterval); if (!cfg.isSet("forget.penalty")) cfg.set("forget.penalty", forgetPenalty); if (!cfg.isSet("crafting_queue")) cfg.set("crafting_queue", craftingQueue); + if (!cfg.isSet("instant_collect")) cfg.set("instant_collect", instantCollect); if (!cfg.isSet("update_queue_offline")) cfg.set("update_queue_offline", updateQueueOffline); if (!cfg.isSet("finished_message")) cfg.set("finished_message", finishMessage); if (!cfg.isSet("finished_message_interval")) cfg.set("finished_message_interval", finishedMessageInterval); @@ -117,6 +119,7 @@ public static void init() { dataSaveInterval = cfg.getLong("data_save_interval"); forgetPenalty = cfg.getDouble("forget.penalty"); craftingQueue = cfg.getBoolean("crafting_queue"); + instantCollect = cfg.getBoolean("instant_collect"); updateQueueOffline = cfg.getBoolean("update_queue_offline"); finishedMessageInterval = cfg.getInt("finished_message_interval"); finishMessage = cfg.getString("finished_message"); diff --git a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java index 886576e..d32bdce 100644 --- a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java +++ b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java @@ -121,7 +121,7 @@ public static void masterProfession(CommandSender sender, String[] args) { return; } - if (PlayerLoader.getPlayer(((Player) sender).getUniqueId()).hasMastered(table.getName())) { + if (PlayerLoader.getPlayer(player.getUniqueId()).hasMastered(table.getName())) { CodexEngine.get().getMessageUtil().sendMessage("fusion.error.alreadyMastered", sender, new MessageData("sender", sender), 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 79e8132..a759b5a 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java @@ -105,6 +105,11 @@ public void addRecipe(Recipe recipe) { FusionAPI.getEventServices() .getQueueService() .addQueueItem(player, ProfessionsCfg.getTable(profession), this, item); + + if (Cfg.instantCollect && recipe.getCraftingTime() <= 0 && queue.contains(item)) { + item.markDone(); + finishRecipe(item); + } } public void finishAllRecipes() { 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 b0b2871..547789d 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java @@ -94,6 +94,11 @@ public void updateIcon() { this.icon = ProfessionsCfg.getQueueItem(profession, this); } + void markDone() { + this.done = true; + this.visualRemainingItemTime = 0; + } + public String getRecipePath() { return recipe.getRecipePath(); } From 14f97723ed2ad9792698c91c075101462c7d58b9 Mon Sep 17 00:00:00 2001 From: MaksyKun <77341370+MaksyKun@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:36:17 +0200 Subject: [PATCH 2/4] implemented multi crafting schedule on shift-leftclick (auto collect supported) --- .../java/studio/magemonkey/fusion/Fusion.java | 6 +- .../api/events/QueueItemAddedEvent.java | 18 ++ .../api/events/QueueItemFinishedEvent.java | 19 ++ .../api/events/QueueItemRemovedEvent.java | 21 ++ .../api/events/services/QueueService.java | 28 ++- .../studio/magemonkey/fusion/cfg/Cfg.java | 15 +- .../cfg/sql/tables/FusionQueuesSQL.java | 22 +- .../fusion/data/player/FusionPlayer.java | 13 + .../fusion/data/queue/CraftingQueue.java | 14 +- .../fusion/data/queue/QueueItem.java | 38 ++- .../magemonkey/fusion/gui/RecipeGui.java | 227 +++++++++++++++--- .../magemonkey/fusion/util/RecipeAmounts.java | 33 +++ src/main/resources/lang/lang_en.yml | 4 +- 13 files changed, 401 insertions(+), 57 deletions(-) create mode 100644 src/main/java/studio/magemonkey/fusion/util/RecipeAmounts.java diff --git a/src/main/java/studio/magemonkey/fusion/Fusion.java b/src/main/java/studio/magemonkey/fusion/Fusion.java index 18799e3..7eccdca 100644 --- a/src/main/java/studio/magemonkey/fusion/Fusion.java +++ b/src/main/java/studio/magemonkey/fusion/Fusion.java @@ -178,7 +178,9 @@ public double getPlayerCooldown(Player player) { private void notifyForQueue(Player player) { int finishedQueueAmount = PlayerLoader.getPlayer(player.getUniqueId()).getFinishedSize(); if (finishedQueueAmount > 0) { - Cfg.notifyForQueue(player, finishedQueueAmount); + Cfg.notifyForQueue(player, + finishedQueueAmount, + PlayerLoader.getPlayer(player.getUniqueId()).getFinishedOutputAmount()); } } @@ -211,4 +213,4 @@ public void onPlayerQuit(PlayerQuitEvent event) { public static void registerListener(Listener listener) { Bukkit.getPluginManager().registerEvents(listener, Fusion.getInstance()); } -} \ No newline at end of file +} diff --git a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemAddedEvent.java b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemAddedEvent.java index de96dd7..ead943b 100644 --- a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemAddedEvent.java +++ b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemAddedEvent.java @@ -5,6 +5,7 @@ import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.data.queue.CraftingQueue; import studio.magemonkey.fusion.data.queue.QueueItem; +import studio.magemonkey.fusion.util.RecipeAmounts; @Getter public class QueueItemAddedEvent extends FusionEvent { @@ -17,6 +18,10 @@ public class QueueItemAddedEvent extends FusionEvent { * The queue item */ private final QueueItem queueItem; + /** + * The number of recipe executions represented by this event. + */ + private final int recipeAmount; /** * Constructor for the QueueItemAddedEvent @@ -27,8 +32,21 @@ public class QueueItemAddedEvent extends FusionEvent { * @param queueItem The queue item */ public QueueItemAddedEvent(String professionName, Player player, CraftingQueue queue, QueueItem queueItem) { + this(professionName, player, queue, queueItem, 1); + } + + public QueueItemAddedEvent(String professionName, + Player player, + CraftingQueue queue, + QueueItem queueItem, + int recipeAmount) { super(professionName, ProfessionsCfg.getTable(professionName), player); this.queue = queue; this.queueItem = queueItem; + this.recipeAmount = Math.max(0, recipeAmount); + } + + public int getOutputAmount() { + return RecipeAmounts.outputAmount(queueItem.getRecipe()) * recipeAmount; } } diff --git a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemFinishedEvent.java b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemFinishedEvent.java index 04db1e3..2b90a66 100644 --- a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemFinishedEvent.java +++ b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemFinishedEvent.java @@ -7,6 +7,7 @@ import studio.magemonkey.fusion.data.queue.CraftingQueue; import studio.magemonkey.fusion.data.queue.QueueItem; import studio.magemonkey.fusion.data.recipes.RecipeItem; +import studio.magemonkey.fusion.util.RecipeAmounts; import java.util.List; @@ -26,6 +27,10 @@ public class QueueItemFinishedEvent extends FusionEvent { */ @Setter private List resultItems; + /** + * The number of recipe executions represented by this event. + */ + private final int recipeAmount; /** * Constructor for the QueueItemFinishedEvent @@ -41,9 +46,23 @@ public QueueItemFinishedEvent(String professionName, CraftingQueue queue, QueueItem queueItem, List resultItems) { + this(professionName, player, queue, queueItem, resultItems, 1); + } + + public QueueItemFinishedEvent(String professionName, + Player player, + CraftingQueue queue, + QueueItem queueItem, + List resultItems, + int recipeAmount) { super(professionName, ProfessionsCfg.getTable(professionName), player); this.queue = queue; this.queueItem = queueItem; this.resultItems = resultItems; + this.recipeAmount = Math.max(0, recipeAmount); + } + + public int getOutputAmount() { + return RecipeAmounts.outputAmount(queueItem.getRecipe(), resultItems) * recipeAmount; } } diff --git a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemRemovedEvent.java b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemRemovedEvent.java index f5b102b..df6701e 100644 --- a/src/main/java/studio/magemonkey/fusion/api/events/QueueItemRemovedEvent.java +++ b/src/main/java/studio/magemonkey/fusion/api/events/QueueItemRemovedEvent.java @@ -7,6 +7,7 @@ import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.data.queue.CraftingQueue; import studio.magemonkey.fusion.data.queue.QueueItem; +import studio.magemonkey.fusion.util.RecipeAmounts; import java.util.List; @@ -29,6 +30,10 @@ public class QueueItemRemovedEvent extends FusionEvent { * Whether the item was refunded */ private final boolean refunded; + /** + * The number of completed recipe executions represented by this event. + */ + private final int recipeAmount; /** * The refunded items in case `refunded` is `true` */ @@ -53,11 +58,27 @@ public QueueItemRemovedEvent(String professionName, boolean finished, boolean refunded, List refundedItems) { + this(professionName, player, queue, queueItem, finished, refunded, refundedItems, finished ? 1 : 0); + } + + public QueueItemRemovedEvent(String professionName, + Player player, + CraftingQueue queue, + QueueItem queueItem, + boolean finished, + boolean refunded, + List refundedItems, + int recipeAmount) { super(professionName, ProfessionsCfg.getTable(professionName), player); this.queue = queue; this.queueItem = queueItem; this.finished = finished; this.refunded = refunded; this.refundedItems = refundedItems; + this.recipeAmount = finished ? Math.max(0, recipeAmount) : 0; + } + + public int getOutputAmount() { + return RecipeAmounts.outputAmount(queueItem.getRecipe()) * recipeAmount; } } 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 7321d62..73601e3 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 @@ -11,6 +11,7 @@ import studio.magemonkey.fusion.api.events.QueueItemAddedEvent; import studio.magemonkey.fusion.api.events.QueueItemFinishedEvent; import studio.magemonkey.fusion.api.events.QueueItemRemovedEvent; +import studio.magemonkey.fusion.cfg.Cfg; import studio.magemonkey.fusion.cfg.sql.SQLManager; import studio.magemonkey.fusion.data.queue.CraftingQueue; import studio.magemonkey.fusion.data.queue.QueueItem; @@ -34,12 +35,22 @@ public class QueueService { * @param item The queue item that is added to the queue. */ public void addQueueItem(Player player, CraftingTable table, CraftingQueue queue, QueueItem item) { + addQueueItemAndReport(player, table, queue, item); + } + + public boolean addQueueItemAndReport(Player player, CraftingTable table, CraftingQueue queue, QueueItem item) { QueueItemAddedEvent event = new QueueItemAddedEvent(table.getName(), player, queue, item); Bukkit.getPluginManager().callEvent(event); if (!event.isCancelled()) { item.setCraftinQueue(event.getQueue()); event.getQueue().getQueue().add(item); + if (Cfg.instantCollect && item.getRecipe().getCraftingTime() <= 0) { + item.markDone(); + event.getQueue().finishRecipe(item); + } + return true; } + return false; } /** @@ -65,10 +76,16 @@ public void cancelQueueItem(Player player, Bukkit.getPluginManager().callEvent(event); if (!event.isCancelled()) { if (event.isRefunded()) { - CodexEngine.get().getVault().give(event.getPlayer(), item.getRecipe().getConditions().getMoneyCost()); + if (CodexEngine.get().getVault() != null && item.getRecipe().getConditions().getMoneyCost() != 0) { + CodexEngine.get().getVault().give(event.getPlayer(), item.getRecipe().getConditions().getMoneyCost()); + } + if (item.getPaidExpCost() > 0) { + player.giveExp(item.getPaidExpCost()); + item.setPaidExpCost(0); + } Collection refunds = event.getRefundedItems(); - for (ItemStack refundItem : refunds) { + if (refunds != null) for (ItemStack refundItem : refunds) { // If those are not stacked natively, we need to give them one by one if (refundItem.getMaxStackSize() < refundItem.getAmount()) { for (int i = 0; i < refundItem.getAmount(); i++) { @@ -94,9 +111,10 @@ public void cancelQueueItem(Player player, } } event.getQueue().getQueue().remove(item); - for (Map.Entry entry : event.getQueue().getQueuedItems().entrySet()) { - if (entry.getValue().equals(item)) { - event.getQueue().getQueuedItems().remove(entry.getKey()); + var queuedItemsIterator = event.getQueue().getQueuedItems().entrySet().iterator(); + while (queuedItemsIterator.hasNext()) { + if (queuedItemsIterator.next().getValue().equals(item)) { + queuedItemsIterator.remove(); break; } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java index c333f64..bf45b55 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java @@ -37,7 +37,8 @@ public final class Cfg { public static boolean useCustomFormula = true; - public static String finishMessage = "&aYou have crafting items ready for pickup! ($)"; + public static String finishMessage = + "&aYou have &e$ recipe(s) &aproducing &e$output item(s) &aready for pickup!"; public static List disabledVanillaRecipes = new ArrayList<>(); public static List autoJoinProfessions = new ArrayList<>(); @@ -152,7 +153,17 @@ private static void reload(FileConfiguration cfg, File file) { } public static void notifyForQueue(Player player, int amount) { - player.sendMessage(finishMessage.replace("$", String.valueOf(amount)).replace("&", "§")); + notifyForQueue(player, amount, amount); + } + + public static void notifyForQueue(Player player, long recipeAmount, long outputAmount) { + player.sendMessage(finishMessage + .replace("$", String.valueOf(recipeAmount)) + .replace("$", String.valueOf(recipeAmount)) + .replace("$", String.valueOf(outputAmount)) + .replace("$", String.valueOf(outputAmount)) + .replace("$", String.valueOf(recipeAmount)) + .replace("&", "§")); } public static void migrateOldTypes(FileConfiguration cfg) { diff --git a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java index a92e615..fc2efae 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java @@ -26,6 +26,7 @@ public FusionQueuesSQL() { + "RecipePath varchar(100)," + "CraftingTime numeric," + "SavedSeconds numeric," + + "PaidExpCost numeric DEFAULT 0," + "Timestamp BIGINT)")) { create.execute(); } catch (SQLException e) { @@ -34,6 +35,13 @@ public FusionQueuesSQL() { .warning("[SQL:FusionQueuesSQL:FusionQueuesSQL] Something went wrong with the sql-connection: " + e.getMessage()); } + // Existing installations need the new persisted queue-cost column as well. + try (PreparedStatement alter = SQLManager.connection() + .prepareStatement("ALTER TABLE " + Table + " ADD COLUMN PaidExpCost numeric DEFAULT 0")) { + alter.execute(); + } catch (SQLException ignored) { + // The column already exists on current installations. + } } public boolean setQueueItem(UUID uuid, QueueItem item) { @@ -41,12 +49,13 @@ public boolean setQueueItem(UUID uuid, QueueItem item) { if (item.getId() == -1) { try (PreparedStatement insert = SQLManager.connection() .prepareStatement("INSERT INTO " + Table - + "(UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds) VALUES (?,?,?,?,?)")) { + + "(UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds, PaidExpCost) VALUES (?,?,?,?,?,?)")) { insert.setString(1, uuid.toString()); insert.setString(2, item.getRecipePath()); insert.setLong(3, item.getTimestamp()); insert.setLong(4, item.getRecipe().getCraftingTime()); insert.setLong(5, item.getSavedSeconds()); + insert.setInt(6, item.getPaidExpCost()); insert.execute(); return true; } catch (SQLException e) { @@ -57,9 +66,10 @@ public boolean setQueueItem(UUID uuid, QueueItem item) { } } else { try (PreparedStatement update = SQLManager.connection() - .prepareStatement("UPDATE " + Table + " SET SavedSeconds=? WHERE Id=?")) { + .prepareStatement("UPDATE " + Table + " SET SavedSeconds=?, PaidExpCost=? WHERE Id=?")) { update.setLong(1, item.getSavedSeconds()); - update.setLong(2, item.getId()); + update.setInt(2, item.getPaidExpCost()); + update.setLong(3, item.getId()); update.execute(); return true; } catch (SQLException e) { @@ -110,14 +120,16 @@ public List getQueueItems(UUID uuid, String profession, Category cate continue; } - entries.add(new QueueItem( + QueueItem queueItem = new QueueItem( result.getInt("Id"), profession, category, recipe, result.getLong("Timestamp"), result.getInt("SavedSeconds") - )); + ); + queueItem.setPaidExpCost(result.getInt("PaidExpCost")); + entries.add(queueItem); } } } catch (SQLException e) { 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 0d9ca4c..a2fd56e 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java @@ -13,6 +13,7 @@ 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.util.RecipeAmounts; import java.util.Collection; import java.util.Map; @@ -350,6 +351,18 @@ public int getFinishedSize() { return size; } + public int getFinishedOutputAmount() { + int amount = 0; + for (CraftingQueue queue : cachedQueues.values()) { + for (QueueItem item : queue.getQueue()) { + if (item.isDone()) { + amount += RecipeAmounts.outputAmount(item.getRecipe()); + } + } + } + return amount; + } + public void save() { save(false); } 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 a759b5a..d334736 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java @@ -101,15 +101,15 @@ public void run() { } public void addRecipe(Recipe recipe) { + addRecipe(recipe, 0); + } + + public boolean addRecipe(Recipe recipe, int paidExpCost) { QueueItem item = new QueueItem(-1, profession, category, recipe, System.currentTimeMillis(), 0); - FusionAPI.getEventServices() + item.setPaidExpCost(Math.max(0, paidExpCost)); + return FusionAPI.getEventServices() .getQueueService() - .addQueueItem(player, ProfessionsCfg.getTable(profession), this, item); - - if (Cfg.instantCollect && recipe.getCraftingTime() <= 0 && queue.contains(item)) { - item.markDone(); - finishRecipe(item); - } + .addQueueItemAndReport(player, ProfessionsCfg.getTable(profession), this, item); } public void finishAllRecipes() { 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 547789d..0d7d412 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java @@ -1,6 +1,5 @@ package studio.magemonkey.fusion.data.queue; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; import lombok.Setter; @@ -10,8 +9,9 @@ import studio.magemonkey.fusion.data.professions.pattern.Category; import studio.magemonkey.fusion.data.recipes.Recipe; +import java.util.Objects; + @Getter -@AllArgsConstructor public class QueueItem { private long id; @@ -24,12 +24,44 @@ public class QueueItem { private boolean done; private int savedSeconds; + /** Experience charged when this queue item was created, for cancellation refunds. */ + @Setter + private int paidExpCost; + private boolean isRunning = false; private CraftingQueue craftingQueue; @Getter private int visualRemainingItemTime; + /** + * Retains the original public constructor shape for integrations compiled + * against the queue API. Newly persisted costs are initialized separately. + */ + public QueueItem(long id, + String profession, + Category category, + @NotNull Recipe recipe, + ItemStack icon, + long timestamp, + boolean done, + int savedSeconds, + boolean isRunning, + CraftingQueue craftingQueue, + int visualRemainingItemTime) { + this.id = id; + this.profession = profession; + this.category = category; + this.recipe = Objects.requireNonNull(recipe, "recipe"); + this.icon = icon; + this.timestamp = timestamp; + this.done = done; + this.savedSeconds = savedSeconds; + this.isRunning = isRunning; + this.craftingQueue = craftingQueue; + this.visualRemainingItemTime = visualRemainingItemTime; + } + public QueueItem(int id, String profession, Category category, @@ -94,7 +126,7 @@ public void updateIcon() { this.icon = ProfessionsCfg.getQueueItem(profession, this); } - void markDone() { + public void markDone() { this.done = true; this.visualRemainingItemTime = 0; } diff --git a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java index 91f91bd..8cafebd 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java @@ -35,6 +35,7 @@ import studio.magemonkey.fusion.cfg.CraftingRequirementsCfg; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; import studio.magemonkey.fusion.data.professions.pattern.Category; import studio.magemonkey.fusion.data.professions.pattern.InventoryPattern; import studio.magemonkey.fusion.data.queue.CraftingQueue; @@ -684,34 +685,175 @@ private boolean canCraft(CalculatedRecipe calculatedRecipe, int slot) { return false; } - // Check queue limits - int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); - int categoryLimit = - PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + "." + category.getName() + ".limit"); - int professionLimit = PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + ".limit"); - int limit = PlayerUtil.getPermOption(player, "fusion.queue.limit"); + // Instant-collect recipes never occupy a queue slot. + if (!(Cfg.instantCollect && recipe.getCraftingTime() <= 0)) { + // Check queue limits + int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); + int categoryLimit = + PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + "." + category.getName() + ".limit"); + int professionLimit = PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + ".limit"); + int limit = PlayerUtil.getPermOption(player, "fusion.queue.limit"); + + if (categoryLimit > 0 && limits[0] >= categoryLimit) { + CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullCategory", + player, + new MessageData("limit", categoryLimit), + new MessageData("category", category.getName()), + new MessageData("profession", table.getName())); + return false; + } else if (professionLimit > 0 && limits[1] >= professionLimit) { + CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullProfession", + player, + new MessageData("limit", professionLimit), + new MessageData("profession", table.getName())); + return false; + } else if (limit > 0 && limits[2] >= limit) { + CodexEngine.get() + .getMessageUtil() + .sendMessage("fusion.queue.fullGlobal", player, new MessageData("limit", limit)); + return false; + } + } - if (categoryLimit > 0 && limits[0] >= categoryLimit) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullCategory", - player, - new MessageData("limit", categoryLimit), - new MessageData("category", category.getName()), - new MessageData("profession", table.getName())); - return false; - } else if (professionLimit > 0 && limits[1] >= professionLimit) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullProfession", - player, - new MessageData("limit", professionLimit), - new MessageData("profession", table.getName())); - return false; - } else if (limit > 0 && limits[2] >= limit) { - CodexEngine.get() - .getMessageUtil() - .sendMessage("fusion.queue.fullGlobal", player, new MessageData("limit", limit)); + return true; + } + + private int getMaximumQueueAmount(CalculatedRecipe calculatedRecipe, int slot) { + if (!canCraft(calculatedRecipe, slot)) { + return 0; + } + + Recipe recipe = calculatedRecipe.getRecipe(); + Map available = new HashMap<>(); + for (ItemStack item : player.getInventory().getContents()) { + if (item == null || item.getType() == Material.AIR) continue; + available.merge(IngredientFingerprint.of(item), item.getAmount(), Integer::sum); + } + + Map required = new HashMap<>(); + for (ItemStack item : recipe.getItemsToTake()) { + if (item == null || item.getType() == Material.AIR) continue; + required.merge(IngredientFingerprint.of(item), item.getAmount(), Integer::sum); + } + + int maximum = Integer.MAX_VALUE; + for (Map.Entry entry : required.entrySet()) { + int needed = entry.getValue(); + if (needed <= 0) continue; + maximum = Math.min(maximum, available.getOrDefault(entry.getKey(), 0) / needed); + } + + int expCost = recipe.getConditions().getExpCost(); + if (expCost > 0) { + maximum = Math.min(maximum, ExperienceManager.getTotalExperience(player) / expCost); + } + + double moneyCost = recipe.getConditions().getMoneyCost(); + if (moneyCost > 0 && CodexEngine.get().getVault() != null) { + maximum = Math.min(maximum, (int) Math.floor( + CodexEngine.get().getVault().getBalance(player) / moneyCost)); + } + + PlayerRecipeLimit recipeLimit = PlayerLoader.getPlayer(player).getRecipeLimit(recipe); + if (recipe.getCraftingLimit() > 0) { + if (recipeLimit.getLimit() > 0 && recipeLimit.getCooldownTimestamp() > 0 && !recipeLimit.hasCooldown()) { + recipeLimit.resetLimit(); + } + int queuedForRecipe = (int) queue.getQueue().stream() + .filter(item -> item.getRecipe().equals(recipe)) + .count(); + maximum = Math.min(maximum, + Math.max(0, recipe.getCraftingLimit() - recipeLimit.getLimit() - queuedForRecipe)); + } + + if (!(Cfg.instantCollect && recipe.getCraftingTime() <= 0)) { + int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); + int categoryLimit = + PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + "." + category.getName() + ".limit"); + int professionLimit = PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + ".limit"); + int globalLimit = PlayerUtil.getPermOption(player, "fusion.queue.limit"); + if (categoryLimit > 0) maximum = Math.min(maximum, Math.max(0, categoryLimit - limits[0])); + if (professionLimit > 0) maximum = Math.min(maximum, Math.max(0, professionLimit - limits[1])); + if (globalLimit > 0) maximum = Math.min(maximum, Math.max(0, globalLimit - limits[2])); + } + + // A recipe with no consumable/cost/limit must still have a finite bulk operation. + return Math.max(0, Math.min(maximum, 1024)); + } + + private List takeIngredients(Recipe recipe) { + List removed = new ArrayList<>(); + PlayerInventory inventory = player.getInventory(); + for (ItemStack required : recipe.getItemsToTake()) { + if (required == null || required.getType() == Material.AIR) continue; + int need = required.getAmount(); + IngredientFingerprint fingerprint = IngredientFingerprint.of(required); + for (int slot = 0; slot < inventory.getSize() && need > 0; slot++) { + ItemStack present = inventory.getItem(slot); + if (present == null || present.getType() == Material.AIR || + !fingerprint.equals(IngredientFingerprint.of(present))) continue; + int taken = Math.min(need, present.getAmount()); + ItemStack refundItem = required.clone(); + refundItem.setAmount(taken); + removed.add(refundItem); + present.setAmount(present.getAmount() - taken); + inventory.setItem(slot, present.getAmount() <= 0 ? null : present); + need -= taken; + } + if (need > 0) { + giveItems(removed); + return null; + } + } + return removed; + } + + private void giveItems(Collection items) { + if (items == null || items.isEmpty()) return; + Collection overflow = player.getInventory().addItem( + items.stream().map(ItemStack::clone).toArray(ItemStack[]::new)).values(); + for (ItemStack item : overflow) { + player.getWorld().dropItemNaturally(player.getLocation(), item); + } + } + + private boolean queueOne(CalculatedRecipe calculatedRecipe, int slot, boolean reportErrors) { + if (!canCraft(calculatedRecipe, slot)) return false; + Recipe recipe = calculatedRecipe.getRecipe(); + List removed = takeIngredients(recipe); + if (removed == null) { + if (reportErrors) { + CodexEngine.get().getMessageUtil() + .sendMessage("fusion.error.insufficientItems", player, new MessageData("recipe", recipe)); + } return false; } - return true; + int expCost = recipe.getConditions().getExpCost(); + double moneyCost = recipe.getConditions().getMoneyCost(); + if (expCost > 0) player.giveExp(-expCost); + if (moneyCost != 0 && CodexEngine.get().getVault() != null) { + CodexEngine.get().getVault().take(player, moneyCost); + } + + boolean added = queue.addRecipe(recipe, expCost); + if (!added) { + giveItems(removed); + if (expCost > 0) player.giveExp(expCost); + if (moneyCost != 0 && CodexEngine.get().getVault() != null) { + CodexEngine.get().getVault().give(player, moneyCost); + } + } + return added; + } + + private void queueMaximum(int slot) { + CalculatedRecipe calculatedRecipe = recipes.get(slot); + if (calculatedRecipe == null) return; + int maximum = getMaximumQueueAmount(calculatedRecipe, slot); + for (int i = 0; i < maximum; i++) { + if (!queueOne(calculatedRecipe, slot, i == 0)) break; + } } private boolean craft(int slot, boolean addToCursor) { @@ -816,8 +958,11 @@ private boolean craft(int slot, boolean addToCursor) { return false; } - // All ingredients were successfully removed; add those to refund list - refund.addAll(removedSoFar); + // Manual crafting uses this list while its delayed task is running. Queue items + // persist their costs on the queue item instead, so they must not share it. + if (!Cfg.craftingQueue) { + refund.addAll(removedSoFar); + } // // ─── 3) Proceed with cooldown / boss‐bar / giving the result ─── @@ -905,9 +1050,21 @@ private boolean craft(int slot, boolean addToCursor) { } }); } else { - if (recipe.getConditions().getMoneyCost() != 0 && CodexEngine.get().getVault() != null) - CodexEngine.get().getVault().take(this.player, recipe.getConditions().getMoneyCost()); - this.queue.addRecipe(this.recipes.get(slot).getRecipe()); + int expCost = recipe.getConditions().getExpCost(); + double moneyCost = recipe.getConditions().getMoneyCost(); + if (expCost > 0) player.giveExp(-expCost); + if (moneyCost != 0 && CodexEngine.get().getVault() != null) { + CodexEngine.get().getVault().take(this.player, moneyCost); + } + boolean added = this.queue.addRecipe(recipe, expCost); + if (!added) { + giveItems(removedSoFar); + if (expCost > 0) player.giveExp(expCost); + if (moneyCost != 0 && CodexEngine.get().getVault() != null) { + CodexEngine.get().getVault().give(this.player, moneyCost); + } + return false; + } } return true; } @@ -1005,7 +1162,11 @@ public void click(InventoryClickEvent event) { event.setCancelled(true); return; } - if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY) { + boolean shiftCraftAll = Cfg.craftingQueue + && event.isShiftClick() + && event.isLeftClick() + && slots[event.getRawSlot()].equals(Slot.BASE_RESULT_SLOT); + if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY && !shiftCraftAll) { event.setCancelled(true); event.setResult(Event.Result.DENY); return; @@ -1046,7 +1207,11 @@ public void click(InventoryClickEvent event) { event.setResult(Event.Result.DENY); Fusion.getInstance().runSync(() -> { this.reloadRecipes(); - this.craft(event.getRawSlot(), false); + if (Cfg.craftingQueue && event.isShiftClick() && event.isLeftClick()) { + this.queueMaximum(event.getRawSlot()); + } else { + this.craft(event.getRawSlot(), false); + } this.reloadRecipesTask(); }); return; diff --git a/src/main/java/studio/magemonkey/fusion/util/RecipeAmounts.java b/src/main/java/studio/magemonkey/fusion/util/RecipeAmounts.java new file mode 100644 index 0000000..f69afb6 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/util/RecipeAmounts.java @@ -0,0 +1,33 @@ +package studio.magemonkey.fusion.util; + +import studio.magemonkey.fusion.data.recipes.Recipe; +import studio.magemonkey.fusion.data.recipes.RecipeItem; + +import java.util.List; + +/** + * Amounts produced by a recipe, using the same result rules as the queue. + */ +public final class RecipeAmounts { + + private RecipeAmounts() { + } + + public static int outputAmount(Recipe recipe) { + return outputAmount(recipe, recipe.getResults().getItems()); + } + + public static int outputAmount(Recipe recipe, List resultItems) { + if (!recipe.getResults().hasCommandsOrItems()) { + return Math.max(0, recipe.getSettings().getRecipeItem().getAmount()); + } + if (resultItems == null) { + return 0; + } + return resultItems.stream() + .filter(item -> item != null) + .mapToInt(RecipeItem::getAmount) + .filter(amount -> amount > 0) + .sum(); + } +} diff --git a/src/main/resources/lang/lang_en.yml b/src/main/resources/lang/lang_en.yml index dcf3716..3c8ac85 100644 --- a/src/main/resources/lang/lang_en.yml +++ b/src/main/resources/lang/lang_en.yml @@ -46,7 +46,7 @@ fusion: fullGlobal: "&cYou have reached the global maximum of your queue size." fullProfession: "&cYou have reached the profession maximum of your queue size. ($)" fullCategory: "&cYou have reached the category maximum of your queue size. ($ of profession $)" - finished: "&aYou have crafting items ready for pickup! ($)" + finished: "&aYou have &e$ recipe(s) &aproducing &e$output item(s) &aready for pickup!" cancelled: "&cYour crafting queue has been cancelled." show: noUsage: "&cThere is no usage for this ingredient." @@ -116,4 +116,4 @@ player: admin: expChanged: "&aYou changed the experience of &3$ &afrom &3$ &ato &3$" - levelChanged: "&aYou changed the level of &3$ &afrom &3$ &ato &3$" \ No newline at end of file + levelChanged: "&aYou changed the level of &3$ &afrom &3$ &ato &3$" From f0c06cfffc862c3ba6a1195904422ce6f0d9c536 Mon Sep 17 00:00:00 2001 From: MaksyKun <77341370+MaksyKun@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:51:47 +0200 Subject: [PATCH 3/4] initial refactoring task for optimized persistence & gui handling --- doc/persistence.md | 15 + pom.xml | 9 + .../java/studio/magemonkey/fusion/Fusion.java | 39 +- .../fusion/api/events/FusionEvent.java | 4 +- .../api/events/services/QueueService.java | 157 +- .../magemonkey/fusion/cfg/ProfessionsCfg.java | 7 + .../magemonkey/fusion/cfg/sql/SQLManager.java | 45 +- .../cfg/sql/tables/FusionProfessionsSQL.java | 70 +- .../cfg/sql/tables/FusionQueuesSQL.java | 79 +- .../cfg/sql/tables/FusionRecipeLimitsSQL.java | 213 ++- .../fusion/crafting/CraftingChecks.java | 47 + .../fusion/crafting/CraftingCostService.java | 70 + .../fusion/crafting/CraftingResult.java | 18 + .../crafting/CraftingRewardService.java | 41 + .../crafting/ManualCraftingService.java | 83 + .../fusion/crafting/QueueCraftingService.java | 53 + .../fusion/data/player/FusionPlayer.java | 149 +- .../fusion/data/player/PlayerLoader.java | 1 + .../fusion/data/player/PlayerRecipeLimit.java | 14 +- .../fusion/data/professions/Profession.java | 27 +- .../professions/pattern/InventoryPattern.java | 13 +- .../fusion/data/queue/CraftingQueue.java | 47 +- .../fusion/data/queue/CraftingReceipt.java | 54 + .../fusion/data/queue/QueueItem.java | 64 +- .../fusion/data/queue/QueueProgress.java | 29 + .../fusion/data/recipes/CalculatedRecipe.java | 31 +- .../magemonkey/fusion/gui/CategoryGui.java | 4 +- .../fusion/gui/ManualRecipeGUI.java | 38 + .../fusion/gui/ProfessionGuiRegistry.java | 4 +- .../fusion/gui/QueuedRecipeGUI.java | 78 + .../magemonkey/fusion/gui/RecipeGui.java | 1427 ++--------------- .../fusion/gui/RecipeGuiFactory.java | 13 + .../fusion/gui/recipe/CompiledPattern.java | 21 + .../gui/recipe/GuiUpdateCoordinator.java | 34 + .../fusion/gui/recipe/PatternCompiler.java | 40 + .../gui/recipe/RecipeGuiEventRouter.java | 20 +- .../fusion/gui/recipe/RecipeViewSnapshot.java | 28 + .../fusion/gui/recipe/SlotRenderer.java | 13 + .../fusion/gui/recipe/SlotRole.java | 5 + .../fusion/cfg/sql/PersistenceTest.java | 248 +++ .../fusion/crafting/CraftingReceiptTest.java | 64 + .../crafting/ManualCraftingServiceTest.java | 74 + .../crafting/QueueCancellationTest.java | 101 ++ .../fusion/data/QueueProgressTest.java | 46 + .../fusion/gui/GuiUpdateCoordinatorTest.java | 30 + .../fusion/gui/PatternCompilerTest.java | 28 + .../fusion/gui/QueuedRecipeGuiTest.java | 73 + 47 files changed, 2078 insertions(+), 1690 deletions(-) create mode 100644 doc/persistence.md create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/CraftingChecks.java create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/CraftingCostService.java create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/CraftingResult.java create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/CraftingRewardService.java create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/ManualCraftingService.java create mode 100644 src/main/java/studio/magemonkey/fusion/crafting/QueueCraftingService.java create mode 100644 src/main/java/studio/magemonkey/fusion/data/queue/CraftingReceipt.java create mode 100644 src/main/java/studio/magemonkey/fusion/data/queue/QueueProgress.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/ManualRecipeGUI.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/QueuedRecipeGUI.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/RecipeGuiFactory.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/CompiledPattern.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/GuiUpdateCoordinator.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/PatternCompiler.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeViewSnapshot.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRenderer.java create mode 100644 src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRole.java create mode 100644 src/test/java/studio/magemonkey/fusion/cfg/sql/PersistenceTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/crafting/CraftingReceiptTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/crafting/ManualCraftingServiceTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/crafting/QueueCancellationTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/data/QueueProgressTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/gui/GuiUpdateCoordinatorTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/gui/PatternCompilerTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java diff --git a/doc/persistence.md b/doc/persistence.md new file mode 100644 index 0000000..2c28bb5 --- /dev/null +++ b/doc/persistence.md @@ -0,0 +1,15 @@ +# Player-state persistence + +Recipe limits, profession state, and auto-crafting settings are read from SQL when requested. Changes are written immediately. Experience and recipe-limit increments use SQL arithmetic rather than replacing a value loaded earlier in the session. Normal logout no longer writes a player-state snapshot. + +Crafting queues retain live objects for Bukkit scheduling and GUI references. Enqueue inserts the row immediately and retains its generated ID. The active item is checkpointed every second; waiting items do not require a write every second. Failed checkpoints remain pending for retry while the queue is loaded. Logout completes its final checkpoints synchronously before unloading, and saving an online player does not cancel their queue task. + +Reload preserves the persisted crafting duration, progress, paid experience cost, and insertion order. Completed items remain completed. Offline time is distributed sequentially from the first unfinished item's checkpoint, then the resulting progress is saved so another rejoin cannot apply the same interval again. Disabling offline progress leaves saved progress unchanged. + +Collecting a queued craft deletes its completed row and consumes its crafting limit in one database transaction. A failed transaction leaves the row available. Rewards are delivered only after that transaction succeeds. Cancellation similarly requires a successful deletion before refunding. Bukkit inventory changes and commands cannot be part of the SQL transaction: a process crash after commit but before reward delivery remains a delivery-loss window. + +Calculated recipe eligibility is no longer shared through the global GUI cache. Rendering and craft attempts evaluate current requirements. Static recipe configuration and live queue objects still remain in memory; this is not a mechanism for synchronizing active queues between multiple servers. + +The direct SQL calls run on the server thread where Bukkit state is involved. Indexed lookups and active-item checkpoints limit unnecessary work, but database latency now directly affects those operations. MySQL/MariaDB load testing is needed before rolling out to a busy remote-database installation. Existing corrupted values are not reconstructed by this change. + +Regression coverage uses temporary SQLite databases and includes repeated saves, persisted durations/costs/timestamps, queue order and category matching, completed reloads, permanent limits and cooldown expiry, stale-object increments, atomic claims and rollback, profession writes, and offline progression enabled/disabled. It does not substitute for a live Minecraft or MySQL/MariaDB integration test. diff --git a/pom.xml b/pom.xml index 02eb7c2..1496a00 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,15 @@ + + com.github.MilkBowl + VaultAPI + 1.7.1 + test + + ** + + studio.magemonkey codex diff --git a/src/main/java/studio/magemonkey/fusion/Fusion.java b/src/main/java/studio/magemonkey/fusion/Fusion.java index 7eccdca..75564ba 100644 --- a/src/main/java/studio/magemonkey/fusion/Fusion.java +++ b/src/main/java/studio/magemonkey/fusion/Fusion.java @@ -70,14 +70,12 @@ public void reloadConfig() { hookManager = new HookManager(); Cfg.init(); - Bukkit.getScheduler().runTaskAsynchronously(this, () -> { - ProfessionsCfg.init(); - EditorRegistry.reload(); - SQLManager.init(); - BrowseConfig.load(); - ShowRecipesCfg.load(); - DivinityService.init(); - }); + ProfessionsCfg.init(); + EditorRegistry.reload(); + SQLManager.init(); + BrowseConfig.load(); + ShowRecipesCfg.load(); + DivinityService.init(); } @Override @@ -118,12 +116,10 @@ public void onLoad() { public void onEnable() { super.onEnable(); this.reloadConfig(); - Bukkit.getScheduler().runTaskAsynchronously(this, () -> { - Fusion.getInstance().getLogger().info("Attempting to migrate data into SQL [ExperienceManager]."); - ExperienceManager.migrateIntoSQL(); - Fusion.getInstance().getLogger().info("Attempting to migrate data into SQL [PConfigManager]."); - PConfigManager.migrateIntoSQL(); - }); + Fusion.getInstance().getLogger().info("Attempting to migrate data into SQL [ExperienceManager]."); + ExperienceManager.migrateIntoSQL(); + Fusion.getInstance().getLogger().info("Attempting to migrate data into SQL [PConfigManager]."); + PConfigManager.migrateIntoSQL(); LevelFunction.generate(200); this.getCommand("craft").setExecutor(new Commands()); this.getCommand("fusion-editor").setExecutor(new FusionEditorCommand()); @@ -192,17 +188,10 @@ private void runQueueTask() { @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - Bukkit.getScheduler().runTaskAsynchronously(this, () -> { - PlayerLoader.getPlayerBlocking(player, 5000); // Wait up to 5s for any pending saves to finish - Bukkit.getScheduler().runTask(this, () -> { - if(!Cfg.autoJoinProfessions.isEmpty()) { - Cfg.autoJoinProfessions(player); - } - if (Cfg.craftingQueue) { - notifyForQueue(player); - } - }); - }); + // Player/queue creation touches Bukkit state and must run on the server thread. + PlayerLoader.getPlayer(player); + if (!Cfg.autoJoinProfessions.isEmpty()) Cfg.autoJoinProfessions(player); + if (Cfg.craftingQueue) notifyForQueue(player); } @EventHandler diff --git a/src/main/java/studio/magemonkey/fusion/api/events/FusionEvent.java b/src/main/java/studio/magemonkey/fusion/api/events/FusionEvent.java index 4462a21..f38eb9a 100644 --- a/src/main/java/studio/magemonkey/fusion/api/events/FusionEvent.java +++ b/src/main/java/studio/magemonkey/fusion/api/events/FusionEvent.java @@ -32,6 +32,7 @@ public class FusionEvent extends Event implements Cancellable { * The fusion player instance of the player */ private final FusionPlayer fusionPlayer; + private boolean cancelled; /** * Constructor for the FusionEvent @@ -49,11 +50,12 @@ public FusionEvent(String professionName, CraftingTable craftingTable, Player pl @Override public boolean isCancelled() { - return false; + return cancelled; } @Override public void setCancelled(boolean b) { + cancelled = b; } @NotNull 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 73601e3..10a4920 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 @@ -1,5 +1,10 @@ package studio.magemonkey.fusion.api.events.services; +import studio.magemonkey.fusion.crafting.CraftingResult; +import studio.magemonkey.fusion.crafting.CraftingCostService; +import studio.magemonkey.fusion.crafting.CraftingRewardService; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import studio.magemonkey.fusion.data.queue.QueueProgress; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -39,18 +44,24 @@ public void addQueueItem(Player player, CraftingTable table, CraftingQueue queue } public boolean addQueueItemAndReport(Player player, CraftingTable table, CraftingQueue queue, QueueItem item) { + return addQueueItemResult(player, table, queue, item) == CraftingResult.SUCCESS; + } + + public CraftingResult addQueueItemResult( + Player player, CraftingTable table, CraftingQueue queue, QueueItem item) { QueueItemAddedEvent event = new QueueItemAddedEvent(table.getName(), player, queue, item); Bukkit.getPluginManager().callEvent(event); if (!event.isCancelled()) { item.setCraftinQueue(event.getQueue()); + if (!SQLManager.queues().setQueueItem(player.getUniqueId(), item)) return CraftingResult.STORAGE_FAILED; event.getQueue().getQueue().add(item); if (Cfg.instantCollect && item.getRecipe().getCraftingTime() <= 0) { item.markDone(); event.getQueue().finishRecipe(item); } - return true; + return CraftingResult.SUCCESS; } - return false; + return CraftingResult.EVENT_CANCELLED; } /** @@ -71,59 +82,37 @@ public void cancelQueueItem(Player player, boolean finished, boolean refunded, List refundItems) { + cancelQueueItemAndReport(player, table, queue, item, finished, refunded, refundItems); + } + + public CraftingResult cancelQueueItemAndReport( + Player player, CraftingTable table, CraftingQueue queue, QueueItem item, + boolean finished, boolean refunded, List refundItems) { + if (!queue.getQueue().contains(item)) return CraftingResult.NOT_FOUND; QueueItemRemovedEvent event = new QueueItemRemovedEvent(table.getName(), player, queue, item, finished, refunded, refundItems); Bukkit.getPluginManager().callEvent(event); if (!event.isCancelled()) { + double refundMoney = item.getReceipt() == null ? item.getRecipe().getConditions().getMoneyCost() : item.getReceipt().getMoney(); + if (refunded && refundMoney > 0 && CodexEngine.get().getVault() == null) return CraftingResult.REQUIREMENTS_NOT_MET; + boolean removed = finished + ? SQLManager.queues().claimQueueItem(player.getUniqueId(), item) + : SQLManager.queues().removeQueueItem(item); + if (!removed) return CraftingResult.STORAGE_FAILED; if (event.isRefunded()) { - if (CodexEngine.get().getVault() != null && item.getRecipe().getConditions().getMoneyCost() != 0) { - CodexEngine.get().getVault().give(event.getPlayer(), item.getRecipe().getConditions().getMoneyCost()); - } - if (item.getPaidExpCost() > 0) { - player.giveExp(item.getPaidExpCost()); - item.setPaidExpCost(0); - } - - Collection refunds = event.getRefundedItems(); - if (refunds != null) for (ItemStack refundItem : refunds) { - // If those are not stacked natively, we need to give them one by one - if (refundItem.getMaxStackSize() < refundItem.getAmount()) { - for (int i = 0; i < refundItem.getAmount(); i++) { - ItemStack singleItem = refundItem.clone(); - singleItem.setAmount(1); - Collection notAdded = player.getInventory().addItem(singleItem).values(); - if (!notAdded.isEmpty()) { - for (ItemStack _item : notAdded) { - Objects.requireNonNull(player.getLocation().getWorld()) - .dropItemNaturally(player.getLocation(), _item); - } - } - } - } else { - Collection notAdded = player.getInventory().addItem(refundItem).values(); - if (!notAdded.isEmpty()) { - for (ItemStack _item : notAdded) { - Objects.requireNonNull(player.getLocation().getWorld()) - .dropItemNaturally(player.getLocation(), _item); - } - } - } - } + var receipt = item.getReceipt(); + int xp = receipt == null ? item.getPaidExpCost() : receipt.getExperience(); + double money = receipt == null ? item.getRecipe().getConditions().getMoneyCost() : receipt.getMoney(); + var items = event.getRefundedItems() == null ? List.of() : event.getRefundedItems(); + new CraftingCostService().refund(player, + new CraftingReceipt(items, xp, money)); } event.getQueue().getQueue().remove(item); - var queuedItemsIterator = event.getQueue().getQueuedItems().entrySet().iterator(); - while (queuedItemsIterator.hasNext()) { - if (queuedItemsIterator.next().getValue().equals(item)) { - queuedItemsIterator.remove(); - break; - } - } - if (!SQLManager.queues().removeQueueItem(item)) { - Fusion.getInstance().getLogger().warning("Failed to remove queue item from SQL"); - } + QueueProgress.refreshTimes(queue.getQueue()); + return CraftingResult.SUCCESS; } + return CraftingResult.EVENT_CANCELLED; } - /** * Call the QueueItemFinishedEvent. * @@ -138,6 +127,13 @@ public void finishQueueItem(Player player, CraftingQueue queue, QueueItem item, List resultItems) { + finishQueueItemAndReport(player, table, queue, item, resultItems); + } + + public CraftingResult finishQueueItemAndReport( + Player player, CraftingTable table, CraftingQueue queue, QueueItem item, List resultItems) { + if (!queue.getQueue().contains(item)) return CraftingResult.NOT_FOUND; + if (!item.isDone()) return CraftingResult.NOT_READY; QueueItemFinishedEvent event = new QueueItemFinishedEvent(table.getName(), player, queue, item, resultItems); Bukkit.getPluginManager().callEvent(event); @@ -149,7 +145,7 @@ public void finishQueueItem(Player player, item, false, true, - event.getQueueItem().getRecipe().getItemsToTake()); + item.getReceipt() == null ? item.getRecipe().getItemsToTake() : item.getReceipt().getItems()); event.setCancelled(true); CodexEngine.get().getMessageUtil().sendMessage("fusion.error.recipeLimitReached", player, new MessageData("recipe", event.getQueueItem().getRecipe().getName()), @@ -157,66 +153,15 @@ public void finishQueueItem(Player player, event.getFusionPlayer().getRecipeLimit(event.getQueueItem().getRecipe()).getLimit()), new MessageData("recipe.limit", event.getQueueItem().getRecipe().getCraftingLimit()), new MessageData("limit", event.getQueueItem().getRecipe().getCraftingLimit())); - return; - } - // Items if no commands exist - if (!item.getRecipe().getResults().hasCommandsOrItems()) { - ItemStack result = - event.getQueueItem().getRecipe().getDivinityRecipeMeta() == null ? event.getQueueItem() - .getRecipe() - .getSettings() - .getRecipeItem() - .getItemStack() - : event.getQueueItem().getRecipe().getDivinityRecipeMeta().generateItem(); - // If there is no space in the inventory, drop the items - Collection notAdded = player.getInventory().addItem(result).values(); - if (!notAdded.isEmpty()) { - for (ItemStack _item : notAdded) { - Objects.requireNonNull(player.getLocation().getWorld()) - .dropItemNaturally(player.getLocation(), _item); - } - } - } else { - if (!item.getRecipe().getResults().getCommands().isEmpty()) { - // If there are commands, we need to delay the item giving - DelayedCommand.invoke(Fusion.getInstance(), player, item.getRecipe().getResults().getCommands()); - } - if (!item.getRecipe().getResults().getItems().isEmpty()) { - // If there are items, we need to delay the item giving - for (RecipeItem resultItem : resultItems) { - ItemStack itemStack = resultItem.getItemStack(); - if (itemStack != null) { - Collection remainings = player.getInventory().addItem(itemStack).values(); - if (!remainings.isEmpty()) { - remainings.forEach(_item -> player.getWorld() - .dropItemNaturally(player.getLocation(), _item)); - } - } - } - } - } - - //Experience - long professionExp = item.getRecipe().getResults().getProfessionExp() + (long) ( - item.getRecipe().getResults().getProfessionExp() - * PlayerUtil.getProfessionExpBonusThroughPermissions(player, table.getName())); - if (professionExp > 0) { - FusionAPI.getEventServices() - .getProfessionService() - .giveProfessionExp(player, - event.getCraftingTable(), - professionExp); - } - if (item.getRecipe().getResults().getVanillaExp() > 0) { - player.giveExp(event.getQueueItem().getRecipe().getResults().getVanillaExp()); + return CraftingResult.REQUIREMENTS_NOT_MET; } - - // Increment the limit if existent - if (event.getQueueItem().getRecipe().getCraftingLimit() > 0) - event.getFusionPlayer().incrementLimit(event.getQueueItem().getRecipe()); - - // Remove the item from the queue - event.getQueue().removeRecipe(event.getQueueItem(), false); + // Claim the persisted row before delivering anything. A failed or cancelled + // removal must not deliver a second reward on the next click/rejoin. + var removal = cancelQueueItemAndReport(player, table, queue, item, true, false, List.of()); + if (removal != CraftingResult.SUCCESS) return removal; + new CraftingRewardService().give(player, table, item.getRecipe(), event.getResultItems()); + return CraftingResult.SUCCESS; } + return CraftingResult.EVENT_CANCELLED; } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java index 6d0f6cc..c702e09 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java @@ -121,6 +121,13 @@ private static void loadProfessions(File root) { // Get the YAMLs whole content as a map Map _map = cfg.getValues(true); CraftingTable ct = new CraftingTable(_map); + studio.magemonkey.fusion.gui.recipe.PatternCompiler.compile(ct.getRecipePattern()); + for (Category category : ct.getCategories().values()) { + if (category.getPattern() == null) continue; + Map roles = new HashMap<>(ct.getRecipePattern().getRoles()); + roles.putAll(category.getPattern().getRoles()); + studio.magemonkey.fusion.gui.recipe.PatternCompiler.compile(category.getPattern().getPattern(), roles); + } map.put(ct.getName(), ct); cfgs.put(ct.getName(), cfg); files.put(ct.getName(), file); diff --git a/src/main/java/studio/magemonkey/fusion/cfg/sql/SQLManager.java b/src/main/java/studio/magemonkey/fusion/cfg/sql/SQLManager.java index 47c26f6..11092ef 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/sql/SQLManager.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/sql/SQLManager.java @@ -115,14 +115,45 @@ private static Connection getMariaDBConnection(String host, public static Connection connection() throws SQLException { if (connection == null || connection.isClosed()) { - init(); + // Reconnect without recreating repositories or clearing other players' locks. + connection = openConnection(); } if (connection != null) { return connection; } else { Fusion.getInstance().getLogger().severe("Connection is still null after initialization attempt."); } - return null; + throw new SQLException("Unable to open Fusion database connection"); + } + + /** A dedicated connection for transactions; callers must close it. */ + public static Connection openConnection() throws SQLException { + Connection opened; + if (currentType == DatabaseType.LOCAL) { + opened = getSQLiteConnection(); + } else if (currentType == DatabaseType.MARIADB) { + opened = getMariaDBConnection(host, port, database, user, password); + } else { + opened = getMySQLConnection(host, port, database, user, password); + } + if (opened == null) throw new SQLException("Unable to open Fusion database connection"); + return opened; + } + + public static void ensureIndex(String table, String name, String columns) { + try { + Connection conn = connection(); + try (ResultSet indexes = conn.getMetaData().getIndexInfo(conn.getCatalog(), null, table, false, false)) { + while (indexes.next()) { + if (name.equalsIgnoreCase(indexes.getString("INDEX_NAME"))) return; + } + } + try (Statement statement = conn.createStatement()) { + statement.executeUpdate("CREATE INDEX " + name + " ON " + table + " (" + columns + ")"); + } + } catch (SQLException e) { + Fusion.getInstance().getLogger().warning("Cannot create index " + name + ": " + e.getMessage()); + } } public static FusionPlayersSQL players() { @@ -164,7 +195,7 @@ public static void swapToLocal() { statement.execute("CREATE TABLE IF NOT EXISTS fusion_players(UUID varchar(36) PRIMARY KEY, AutoCrafting boolean DEFAULT false, Locked boolean DEFAULT false)"); // Use SQLite-compatible id column definition statement.execute("CREATE TABLE IF NOT EXISTS fusion_professions(" + getIdColumn(DatabaseType.LOCAL) + " UUID varchar(36), Profession varchar(100), Experience numeric, Mastered boolean, Joined boolean)"); - statement.execute("CREATE TABLE IF NOT EXISTS fusion_queues(" + getIdColumn(DatabaseType.LOCAL) + " UUID varchar(36), RecipePath varchar(100), Timestamp BIGINT, CraftingTime numeric, SavedSeconds numeric)"); + statement.execute("CREATE TABLE IF NOT EXISTS fusion_queues(" + getIdColumn(DatabaseType.LOCAL) + " UUID varchar(36), RecipePath varchar(100), Timestamp BIGINT, CraftingTime numeric, SavedSeconds numeric, PaidExpCost numeric DEFAULT 0, Receipt TEXT)"); } catch (SQLException e) { Fusion.getInstance().getLogger().severe("Error while dropping tables: " + e.getMessage()); @@ -219,7 +250,7 @@ public static void swapToLocal() { try (Connection sqliteConnection = getSQLiteConnection(); PreparedStatement insertStatement = sqliteConnection.prepareStatement( - "INSERT INTO fusion_queues (Id, UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds) VALUES (?, ?, ?, ?, ?, ?)") ) { + "INSERT INTO fusion_queues (Id, UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds, PaidExpCost, Receipt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") ) { insertQueue(resultQueues, insertStatement); } catch (SQLException e) { Fusion.getInstance() @@ -250,7 +281,7 @@ public static void swapToSql() { sqlStatement.execute("CREATE TABLE IF NOT EXISTS fusion_players(UUID varchar(36) PRIMARY KEY, AutoCrafting boolean DEFAULT false, Locked boolean DEFAULT false)"); // Use MySQL-compatible id column definition sqlStatement.execute("CREATE TABLE IF NOT EXISTS fusion_professions(" + getIdColumn(DatabaseType.MYSQL) + " UUID varchar(36), Profession varchar(100), Experience numeric, Mastered boolean, Joined boolean)"); - sqlStatement.execute("CREATE TABLE IF NOT EXISTS fusion_queues(" + getIdColumn(DatabaseType.MYSQL) + " UUID varchar(36), RecipePath varchar(100), Timestamp BIGINT, CraftingTime numeric, SavedSeconds numeric)"); + sqlStatement.execute("CREATE TABLE IF NOT EXISTS fusion_queues(" + getIdColumn(DatabaseType.MYSQL) + " UUID varchar(36), RecipePath varchar(100), Timestamp BIGINT, CraftingTime numeric, SavedSeconds numeric, PaidExpCost numeric DEFAULT 0, Receipt TEXT)"); // Get all data from the local database try (Connection sqliteConnection = getSQLiteConnection(); @@ -326,7 +357,7 @@ private static void insertProfessions(Connection connection, ResultSet resultSet private static void insertQueues(Connection connection, ResultSet resultSet) throws SQLException { String insertQuery = - "INSERT INTO fusion_queues (Id, UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds) VALUES (?, ?, ?, ?, ?, ?)"; + "INSERT INTO fusion_queues (Id, UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds, PaidExpCost, Receipt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; try (PreparedStatement preparedStatement = connection.prepareStatement(insertQuery)) { insertQueue(resultSet, preparedStatement); } @@ -340,6 +371,8 @@ private static void insertQueue(ResultSet resultQueues, PreparedStatement insert insertStatement.setLong(4, resultQueues.getLong("Timestamp")); insertStatement.setDouble(5, resultQueues.getDouble("CraftingTime")); insertStatement.setDouble(6, resultQueues.getDouble("SavedSeconds")); + insertStatement.setInt(7, resultQueues.getInt("PaidExpCost")); + insertStatement.setString(8, resultQueues.getString("Receipt")); insertStatement.executeUpdate(); } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionProfessionsSQL.java b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionProfessionsSQL.java index d5e8dc7..4c423de 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionProfessionsSQL.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionProfessionsSQL.java @@ -31,6 +31,7 @@ public FusionProfessionsSQL() { "[SQL:FusionProfessionsSQL:FusionProfessionsSQL] Something went wrong with the sql-connection: " + e.getMessage()); } + SQLManager.ensureIndex(Table, "fusion_professions_player_name", "UUID, Profession"); } public void setProfession(UUID uuid, Profession profession) { @@ -61,11 +62,12 @@ public void addProfession(Profession profession) { public void updateProfession(Profession profession) { try (PreparedStatement update = SQLManager.connection() - .prepareStatement("UPDATE " + Table + " SET Experience=?, Mastered=?, Joined=? WHERE Id=?")) { + .prepareStatement("UPDATE " + Table + " SET Experience=?, Mastered=?, Joined=? WHERE UUID=? AND Profession=?")) { update.setDouble(1, profession.getExp()); update.setBoolean(2, profession.isMastered()); update.setBoolean(3, profession.isJoined()); - update.setLong(4, profession.getId()); + update.setString(4, profession.getUuid().toString()); + update.setString(5, profession.getName()); update.execute(); } catch (SQLException e) { Fusion.getInstance() @@ -76,6 +78,34 @@ public void updateProfession(Profession profession) { } } + public void incrementExperience(Profession profession, long amount) { + if (!hasProfession(profession.getUuid(), profession.getName())) addProfession(profession); + try (PreparedStatement update = SQLManager.connection().prepareStatement( + "UPDATE " + Table + " SET Experience=Experience+? WHERE UUID=? AND Profession=?")) { + update.setLong(1, amount); + update.setString(2, profession.getUuid().toString()); + update.setString(3, profession.getName()); + update.executeUpdate(); + } catch (SQLException e) { + throw new IllegalStateException("Cannot update profession experience", e); + } + } + + public void setValue(Profession profession, String column, Object value) { + if (!java.util.Set.of("Experience", "Mastered", "Joined").contains(column)) + throw new IllegalArgumentException("Unknown profession column: " + column); + if (!hasProfession(profession.getUuid(), profession.getName())) addProfession(profession); + try (PreparedStatement update = SQLManager.connection().prepareStatement( + "UPDATE " + Table + " SET " + column + "=? WHERE UUID=? AND Profession=?")) { + update.setObject(1, value); + update.setString(2, profession.getUuid().toString()); + update.setString(3, profession.getName()); + update.executeUpdate(); + } catch (SQLException e) { + throw new IllegalStateException("Cannot update profession " + column, e); + } + } + public boolean hasProfession(UUID uuid, String profession) { try (PreparedStatement select = SQLManager.connection() .prepareStatement("SELECT * FROM " + Table + " WHERE UUID=? AND Profession=?")) { @@ -107,19 +137,34 @@ public void removeProfession(String uuid, String profession) { } public List getProfessions(UUID uuid) { - List entries = new ArrayList<>(); - entries.addAll(getProfessions(uuid, true)); - entries.addAll(getProfessions(uuid, false)); - return entries; + return readProfessions(uuid, null); } public List getProfessions(UUID uuid, boolean joined) { + return readProfessions(uuid, joined); + } + + public Profession getProfession(UUID uuid, String name) { + try (PreparedStatement select = SQLManager.connection().prepareStatement( + "SELECT * FROM " + Table + " WHERE UUID=? AND Profession=?")) { + select.setString(1, uuid.toString()); + select.setString(2, name); + try (ResultSet result = select.executeQuery()) { + return result.next() ? new Profession(result.getLong("Id"), uuid, name, + result.getLong("Experience"), result.getBoolean("Mastered"), result.getBoolean("Joined")) : null; + } + } catch (SQLException e) { + throw new IllegalStateException("Cannot read profession " + name, e); + } + } + + private List readProfessions(UUID uuid, Boolean joined) { List entries = new ArrayList<>(); try (PreparedStatement select = SQLManager.connection() - .prepareStatement("SELECT * FROM " + Table + " WHERE UUID=? AND Joined=?")) { + .prepareStatement("SELECT * FROM " + Table + " WHERE UUID=?" + (joined == null ? "" : " AND Joined=?"))) { select.setString(1, uuid.toString()); - select.setBoolean(2, joined); - ResultSet result = select.executeQuery(); + if (joined != null) select.setBoolean(2, joined); + try (ResultSet result = select.executeQuery()) { while (result.next()) { long id = result.getLong("Id"); String profession = result.getString("Profession"); @@ -128,13 +173,10 @@ public List getProfessions(UUID uuid, boolean joined) { boolean joined1 = result.getBoolean("Joined"); entries.add(new Profession(id, uuid, profession, exp, mastered, joined1)); } + } return entries; } catch (SQLException e) { - Fusion.getInstance() - .getLogger() - .warning("[SQL:FusionProfessionsSQL:getProfessions] Something went wrong with the sql-connection: " - + e.getMessage()); + throw new IllegalStateException("Cannot read professions", e); } - return entries; } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java index fc2efae..a48c28a 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionQueuesSQL.java @@ -11,8 +11,10 @@ import studio.magemonkey.fusion.data.recipes.Recipe; import java.sql.PreparedStatement; +import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.util.*; public class FusionQueuesSQL { @@ -42,6 +44,11 @@ public FusionQueuesSQL() { } catch (SQLException ignored) { // The column already exists on current installations. } + try (PreparedStatement alter = SQLManager.connection().prepareStatement( + "ALTER TABLE " + Table + " ADD COLUMN Receipt TEXT")) { + alter.execute(); + } catch (SQLException ignored) { /* Already migrated. Insert fails safely if unavailable. */ } + SQLManager.ensureIndex(Table, "fusion_queues_player", "UUID, Id"); } public boolean setQueueItem(UUID uuid, QueueItem item) { @@ -49,14 +56,19 @@ public boolean setQueueItem(UUID uuid, QueueItem item) { if (item.getId() == -1) { try (PreparedStatement insert = SQLManager.connection() .prepareStatement("INSERT INTO " + Table - + "(UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds, PaidExpCost) VALUES (?,?,?,?,?,?)")) { + + "(UUID, RecipePath, Timestamp, CraftingTime, SavedSeconds, PaidExpCost, Receipt) VALUES (?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS)) { insert.setString(1, uuid.toString()); insert.setString(2, item.getRecipePath()); insert.setLong(3, item.getTimestamp()); - insert.setLong(4, item.getRecipe().getCraftingTime()); + insert.setLong(4, item.getCraftingTime()); insert.setLong(5, item.getSavedSeconds()); insert.setInt(6, item.getPaidExpCost()); - insert.execute(); + insert.setString(7, item.getReceipt() == null ? null : item.getReceipt().encode()); + insert.executeUpdate(); + try (ResultSet keys = insert.getGeneratedKeys()) { + if (!keys.next()) throw new SQLException("Queue insert returned no ID"); + item.setId(keys.getLong(1)); + } return true; } catch (SQLException e) { Fusion.getInstance() @@ -66,12 +78,13 @@ public boolean setQueueItem(UUID uuid, QueueItem item) { } } else { try (PreparedStatement update = SQLManager.connection() - .prepareStatement("UPDATE " + Table + " SET SavedSeconds=?, PaidExpCost=? WHERE Id=?")) { + .prepareStatement("UPDATE " + Table + " SET SavedSeconds=?, PaidExpCost=?, Timestamp=? WHERE Id=? AND UUID=?")) { update.setLong(1, item.getSavedSeconds()); update.setInt(2, item.getPaidExpCost()); - update.setLong(3, item.getId()); - update.execute(); - return true; + update.setLong(3, item.getTimestamp()); + update.setLong(4, item.getId()); + update.setString(5, uuid.toString()); + return update.executeUpdate() == 1; } catch (SQLException e) { Fusion.getInstance() .getLogger() @@ -86,8 +99,7 @@ public boolean removeQueueItem(QueueItem item) { try (PreparedStatement delete = SQLManager.connection() .prepareStatement("DELETE FROM " + Table + " WHERE Id=?")) { delete.setLong(1, item.getId()); - delete.execute(); - return true; + return delete.executeUpdate() == 1; } catch (SQLException e) { Fusion.getInstance() .getLogger() @@ -97,17 +109,46 @@ public boolean removeQueueItem(QueueItem item) { return false; } + public boolean claimQueueItem(UUID uuid, QueueItem item) { + try (Connection connection = SQLManager.openConnection()) { + connection.setAutoCommit(false); + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM " + Table + " WHERE Id=? AND UUID=? AND SavedSeconds>=CraftingTime")) { + delete.setLong(1, item.getId()); + delete.setString(2, uuid.toString()); + if (delete.executeUpdate() != 1 || !SQLManager.recipeLimits().consumeLimit(connection, + uuid, item.getRecipePath(), item.getRecipe().getCraftingLimit(), + item.getRecipe().getCraftingLimitCooldown())) { + connection.rollback(); + return false; + } + connection.commit(); + return true; + } catch (SQLException | RuntimeException e) { + connection.rollback(); + throw e; + } + } catch (SQLException | RuntimeException e) { + Fusion.getInstance().getLogger().warning("Failed to claim queue item " + item.getId() + ": " + e.getMessage()); + return false; + } + } + public List getQueueItems(UUID uuid, String profession, Category category) { List entries = new ArrayList<>(); - String sql = "SELECT * FROM " + Table + " WHERE UUID=? AND RecipePath LIKE ?"; + String sql = "SELECT * FROM " + Table + " WHERE UUID=? ORDER BY Id"; try (PreparedStatement select = SQLManager.connection().prepareStatement(sql)) { select.setString(1, uuid.toString()); - select.setString(2, "%" + profession + "." + category.getName() + "%"); try (ResultSet result = select.executeQuery()) { while (result.next()) { - String recipeStr = result.getString("RecipePath").split("\\.")[2]; - Recipe recipe = category.getRecipe(recipeStr); + String prefix = profession + "." + (category.getName().equals("master") ? "" : category.getName() + "."); + String path = result.getString("RecipePath"); + if (path == null || !path.startsWith(prefix)) continue; + String recipeStr = path.substring(prefix.length()); + Recipe recipe = category.getName().equals("master") + ? category.getRecipes().stream().filter(candidate -> candidate.getRecipePath().equals(path)).findFirst().orElse(null) + : category.getRecipe(recipeStr); if (recipe == null) { Fusion.getInstance() @@ -129,6 +170,9 @@ public List getQueueItems(UUID uuid, String profession, Category cate result.getInt("SavedSeconds") ); queueItem.setPaidExpCost(result.getInt("PaidExpCost")); + queueItem.setReceipt(studio.magemonkey.fusion.data.queue.CraftingReceipt.decode(result.getString("Receipt"))); + queueItem.setId(result.getLong("Id")); + queueItem.restoreCraftingTime(result.getInt("CraftingTime")); entries.add(queueItem); } } @@ -145,7 +189,13 @@ public Map getCraftingQueues(Player player) { Map entries = new HashMap<>(); for (Map.Entry entry : ProfessionsCfg.getMap().entrySet()) { String profession = entry.getKey(); - for (Category category : entry.getValue().getCategories().values()) { + Collection categories = entry.getValue().getCategories().values(); + if (!entry.getValue().getUseCategories() || categories.isEmpty()) { + Category master = new Category("master", "PAPER", entry.getValue().getRecipePattern(), 1); + master.getRecipes().addAll(entry.getValue().getRecipes().values()); + categories = List.of(master); + } + for (Category category : categories) { String path = profession + "." + category.getName(); if (entries.containsKey(path)) continue; entries.putIfAbsent(path, new CraftingQueue(player, profession, category)); @@ -155,7 +205,6 @@ public Map getCraftingQueues(Player player) { } public void saveCraftingQueue(CraftingQueue queue) { - queue.cancelTask(); for (QueueItem item : queue.getQueue()) { if (!setQueueItem(queue.getPlayer().getUniqueId(), item)) { Fusion.getInstance() diff --git a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionRecipeLimitsSQL.java b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionRecipeLimitsSQL.java index 426c9a7..7b78e82 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionRecipeLimitsSQL.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/sql/tables/FusionRecipeLimitsSQL.java @@ -5,6 +5,7 @@ import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; import java.sql.PreparedStatement; +import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.util.HashMap; @@ -30,99 +31,155 @@ public FusionRecipeLimitsSQL() { "[SQL:FusionRecipeLimitsSQL:FusionRecipeLimitsSQL] Something went wrong with the sql-connection: " + e.getMessage()); } + SQLManager.ensureIndex(Table, "fusion_limits_player_recipe", "UUID, RecipePath"); } - public Map getRecipeLimits(UUID uuid) { - Map limits = new HashMap<>(); - try (PreparedStatement select = SQLManager.connection() - .prepareStatement("SELECT * FROM " + Table + " WHERE UUID = ?")) { - select.setString(1, uuid.toString()); - ResultSet result = select.executeQuery(); - while (result.next()) { - String recipePath = result.getString("RecipePath"); - int amount = result.getInt("Amount"); - long timestamp = result.getLong("Timestamp"); - limits.put(recipePath, new PlayerRecipeLimit(recipePath, amount, timestamp)); + /** Fresh database state; expired cooldowns are reset regardless of which UI is open. */ + public synchronized PlayerRecipeLimit getRecipeLimit(UUID uuid, String path) { + try { + expire(uuid, path); + try (PreparedStatement select = SQLManager.connection().prepareStatement( + "SELECT Amount, Timestamp FROM " + Table + " WHERE UUID=? AND RecipePath=? ORDER BY Id")) { + select.setString(1, uuid.toString()); + select.setString(2, path); + try (ResultSet result = select.executeQuery()) { + if (result.next()) return new PlayerRecipeLimit(uuid, path, + result.getInt("Amount"), result.getLong("Timestamp")); + } } + return new PlayerRecipeLimit(uuid, path, 0, -1); } catch (SQLException e) { - Fusion.getInstance() - .getLogger() - .warning( - "[SQL:FusionRecipeLimitsSQL:getRecipeLimits] Something went wrong with the sql-connection: " - + e.getMessage()); + throw new IllegalStateException("Cannot read recipe limit for " + uuid + "/" + path, e); } - return limits; } - public void saveRecipeLimits(UUID uuid, Map recipeLimits) { - // Get all current limits from DB - Map currentLimits = getRecipeLimits(uuid); - - // Remove limits that are no longer present in the provided map - for (String recipePath : currentLimits.keySet()) { - if (!recipeLimits.containsKey(recipePath)) { - PlayerRecipeLimit limit = currentLimits.get(recipePath); - // Limits mit Timestamp = -1 nicht löschen, außer explizit entfernt - if (limit.getCooldownTimestamp() != -1) { - try (PreparedStatement delete = SQLManager.connection().prepareStatement( - "DELETE FROM " + Table + " WHERE UUID = ? AND RecipePath = ?")) { - delete.setString(1, uuid.toString()); - delete.setString(2, recipePath); - delete.execute(); - } catch (SQLException e) { - Fusion.getInstance().getLogger().warning( - "[SQL:FusionRecipeLimitsSQL:saveRecipeLimits] Error at deletion: " + e.getMessage()); - } - } + private void expire(UUID uuid, String path) throws SQLException { + expire(SQLManager.connection(), uuid, path); + } + + private void expire(Connection connection, UUID uuid, String path) throws SQLException { + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM " + Table + " WHERE UUID=? AND RecipePath=? AND Timestamp>0 AND Timestamp<=?")) { + delete.setString(1, uuid.toString()); + delete.setString(2, path); + delete.setLong(3, System.currentTimeMillis()); + delete.executeUpdate(); + } + } + + /** Increment in SQL, never overwrite an amount from a session snapshot. */ + public synchronized void incrementLimit(UUID uuid, String path, int amount, int cooldownSeconds) { + try { + expire(uuid, path); + ensureRow(uuid, path); + String sql = "UPDATE " + Table + " SET Amount=Amount+?" + + (cooldownSeconds > 0 ? ", Timestamp=?" : "") + " WHERE UUID=? AND RecipePath=?"; + try (PreparedStatement update = SQLManager.connection().prepareStatement(sql)) { + int index = 1; + update.setInt(index++, amount); + if (cooldownSeconds > 0) + update.setLong(index++, System.currentTimeMillis() + cooldownSeconds * 1000L); + update.setString(index++, uuid.toString()); + update.setString(index, path); + update.executeUpdate(); } + } catch (SQLException e) { + throw new IllegalStateException("Cannot increment recipe limit for " + uuid + "/" + path, e); + } + } + + private void ensureRow(UUID uuid, String path) throws SQLException { + ensureRow(SQLManager.connection(), uuid, path); + } + + private void ensureRow(Connection connection, UUID uuid, String path) throws SQLException { + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO " + Table + " (UUID, RecipePath, Amount, Timestamp) " + + "SELECT ?, ?, 0, -1 WHERE NOT EXISTS (SELECT 1 FROM " + Table + + " WHERE UUID=? AND RecipePath=?)")) { + insert.setString(1, uuid.toString()); + insert.setString(2, path); + insert.setString(3, uuid.toString()); + insert.setString(4, path); + insert.executeUpdate(); } + } - // Update or Insert limits - for (Map.Entry entry : recipeLimits.entrySet()) { - String recipePath = entry.getKey(); - PlayerRecipeLimit limit = entry.getValue(); - if (limit.getLimit() <= 0) { - // Falls Limit <= 0, löschen (außer Timestamp = -1) - if (limit.getCooldownTimestamp() != -1) { - try (PreparedStatement delete = SQLManager.connection().prepareStatement( - "DELETE FROM " + Table + " WHERE UUID = ? AND RecipePath = ?")) { - delete.setString(1, uuid.toString()); - delete.setString(2, recipePath); - delete.execute(); - } catch (SQLException e) { - Fusion.getInstance().getLogger().warning( - "[SQL:FusionRecipeLimitsSQL:saveRecipeLimits] Error at deletion: " + e.getMessage()); - } + /** Used inside the queue-claim transaction so a failed limit write cannot lose a craft. */ + public boolean consumeLimit(Connection connection, UUID uuid, String path, int maximum, + int cooldownSeconds) throws SQLException { + if (maximum <= 0) return true; + expire(connection, uuid, path); + ensureRow(connection, uuid, path); + try (PreparedStatement update = connection.prepareStatement( + "UPDATE " + Table + " SET Amount=Amount+1, Timestamp=? WHERE UUID=? AND RecipePath=? AND Amount 0 ? System.currentTimeMillis() + cooldownSeconds * 1000L : -1L); + update.setString(2, uuid.toString()); + update.setString(3, path); + update.setInt(4, maximum); + return update.executeUpdate() == 1; + } + } + + public synchronized void resetLimit(UUID uuid, String path) { + try (PreparedStatement delete = SQLManager.connection().prepareStatement( + "DELETE FROM " + Table + " WHERE UUID=? AND RecipePath=?")) { + delete.setString(1, uuid.toString()); + delete.setString(2, path); + delete.executeUpdate(); + } catch (SQLException e) { + throw new IllegalStateException("Cannot reset recipe limit", e); + } + } + + public synchronized void updateCooldown(UUID uuid, String path, long timestamp) { + try { + ensureRow(uuid, path); + try (PreparedStatement update = SQLManager.connection().prepareStatement( + "UPDATE " + Table + " SET Timestamp=? WHERE UUID=? AND RecipePath=?")) { + update.setLong(1, timestamp); + update.setString(2, uuid.toString()); + update.setString(3, path); + update.executeUpdate(); + } + } catch (SQLException e) { + throw new IllegalStateException("Cannot update recipe cooldown", e); + } + } + + public synchronized Map getRecipeLimits(UUID uuid) { + Map limits = new HashMap<>(); + try (PreparedStatement select = SQLManager.connection().prepareStatement( + "SELECT * FROM " + Table + " WHERE UUID=?")) { + select.setString(1, uuid.toString()); + try (ResultSet result = select.executeQuery()) { + while (result.next()) { + String path = result.getString("RecipePath"); + limits.put(path, new PlayerRecipeLimit(uuid, path, + result.getInt("Amount"), result.getLong("Timestamp"))); } - continue; } - // Check if the limit already exists - if (currentLimits.containsKey(recipePath)) { - // Update + } catch (SQLException e) { + throw new IllegalStateException("Cannot read recipe limits", e); + } + return limits; + } + + /** Explicit import API only. Normal player saves must not replay limit snapshots. */ + public synchronized void saveRecipeLimits(UUID uuid, Map limits) { + for (Map.Entry entry : limits.entrySet()) { + try { + ensureRow(uuid, entry.getKey()); try (PreparedStatement update = SQLManager.connection().prepareStatement( - "UPDATE " + Table + " SET Amount = ?, Timestamp = ? WHERE UUID = ? AND RecipePath = ?")) { - update.setInt(1, limit.getLimit()); - update.setLong(2, limit.getCooldownTimestamp()); + "UPDATE " + Table + " SET Amount=?, Timestamp=? WHERE UUID=? AND RecipePath=?")) { + update.setInt(1, entry.getValue().getLimit()); + update.setLong(2, entry.getValue().getCooldownTimestamp()); update.setString(3, uuid.toString()); - update.setString(4, recipePath); - update.execute(); - } catch (SQLException e) { - Fusion.getInstance().getLogger().warning( - "[SQL:FusionRecipeLimitsSQL:saveRecipeLimits] Error at update: " + e.getMessage()); - } - } else { - // Insert - try (PreparedStatement insert = SQLManager.connection().prepareStatement( - "INSERT INTO " + Table + "(UUID, RecipePath, Amount, Timestamp) VALUES(?,?,?,?)")) { - insert.setString(1, uuid.toString()); - insert.setString(2, recipePath); - insert.setInt(3, limit.getLimit()); - insert.setLong(4, limit.getCooldownTimestamp()); - insert.execute(); - } catch (SQLException e) { - Fusion.getInstance().getLogger().warning( - "[SQL:FusionRecipeLimitsSQL:saveRecipeLimits] Error at insert: " + e.getMessage()); + update.setString(4, entry.getKey()); + update.executeUpdate(); } + } catch (SQLException e) { + throw new IllegalStateException("Cannot import recipe limit", e); } } } diff --git a/src/main/java/studio/magemonkey/fusion/crafting/CraftingChecks.java b/src/main/java/studio/magemonkey/fusion/crafting/CraftingChecks.java new file mode 100644 index 0000000..96191bc --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/CraftingChecks.java @@ -0,0 +1,47 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.queue.CraftingQueue; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; +import studio.magemonkey.fusion.util.PlayerUtil; +import java.util.*; + +public final class CraftingChecks { + private CraftingChecks() { } + public static Map inventory(Player player) { + Map counts = new HashMap<>(); + for (ItemStack item : player.getInventory().getContents()) + if (item != null && item.getType() != Material.AIR) + counts.merge(IngredientFingerprint.of(item), item.getAmount(), Integer::sum); + return counts; + } + + public static boolean canCraft(Player player, CraftingTable table, Recipe recipe, CraftingQueue queue) { + try { + if (!CalculatedRecipe.create(recipe, inventory(player), player, table).isCanCraft()) return false; + var owner = PlayerLoader.getPlayer(player); + if (owner.hasRecipeLimitReached(recipe)) return false; + if (queue == null) return true; + long queued = queue.getQueue().stream().filter(item -> item.getRecipePath().equals(recipe.getRecipePath())).count(); + if (recipe.getCraftingLimit() > 0 + && queued + owner.getRecipeLimit(recipe).getLimit() >= recipe.getCraftingLimit()) return false; + if (Cfg.instantCollect && recipe.getCraftingTime() <= 0) return true; + int[] sizes = owner.getQueueSizes(table.getName(), queue.getCategory()); + String[] permissions = {"fusion.queue." + table.getName() + "." + queue.getCategory().getName() + ".limit", + "fusion.queue." + table.getName() + ".limit", "fusion.queue.limit"}; + for (int i = 0; i < permissions.length; i++) { + int maximum = PlayerUtil.getPermOption(player, permissions[i]); + if (maximum > 0 && sizes[i] >= maximum) return false; + } + return true; + } catch (Exception exception) { + studio.magemonkey.fusion.Fusion.getInstance().getLogger().warning("Cannot validate craft: " + exception.getMessage()); + return false; + } + } +} diff --git a/src/main/java/studio/magemonkey/fusion/crafting/CraftingCostService.java b/src/main/java/studio/magemonkey/fusion/crafting/CraftingCostService.java new file mode 100644 index 0000000..c6868f5 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/CraftingCostService.java @@ -0,0 +1,70 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import studio.magemonkey.codex.CodexEngine; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import studio.magemonkey.fusion.data.recipes.Recipe; +import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; +import studio.magemonkey.fusion.util.ExperienceManager; +import java.util.*; + +public final class CraftingCostService { + public CraftingReceipt charge(Player player, Recipe recipe) { + int xp = Math.max(0, recipe.getConditions().getExpCost()); + double money = Math.max(0, recipe.getConditions().getMoneyCost()); + var vault = CodexEngine.get().getVault(); + if (ExperienceManager.getTotalExperience(player) < xp + || (money > 0 && (vault == null || !vault.canPay(player, money)))) return null; + List removed = takeItems(player, recipe.getItemsToTake()); + if (removed == null) return null; + if (money > 0) vault.take(player, money); + if (xp > 0) player.giveExp(-xp); + return new CraftingReceipt(removed, xp, money); + } + + public List takeItems(Player player, Collection requirements) { + List removed = new ArrayList<>(); + var inventory = player.getInventory(); + for (ItemStack required : requirements) { + if (required == null || required.getType() == Material.AIR) continue; + int needed = required.getAmount(); + var fingerprint = IngredientFingerprint.of(required); + for (int slot = 0; slot < inventory.getSize() && needed > 0; slot++) { + ItemStack present = inventory.getItem(slot); + if (present == null || present.getType() == Material.AIR + || !fingerprint.equals(IngredientFingerprint.of(present))) continue; + int count = Math.min(needed, present.getAmount()); + ItemStack actual = present.clone(); + actual.setAmount(count); + removed.add(actual); + ItemStack rest = present.clone(); + rest.setAmount(present.getAmount() - count); + inventory.setItem(slot, rest.getAmount() == 0 ? null : rest); + needed -= count; + } + if (needed > 0) { giveItems(player, removed); return null; } + } + return removed; + } + + public void refund(Player player, CraftingReceipt receipt) { + giveItems(player, receipt.getItems()); + if (receipt.getExperience() > 0) player.giveExp(receipt.getExperience()); + if (receipt.getMoney() > 0) CodexEngine.get().getVault().give(player, receipt.getMoney()); + } + + public void giveItems(Player player, Collection items) { + for (ItemStack item : items) { + int remaining = item.getAmount(); + while (remaining > 0) { + ItemStack part = item.clone(); + part.setAmount(Math.min(remaining, Math.max(1, item.getMaxStackSize()))); + remaining -= part.getAmount(); + player.getInventory().addItem(part).values().forEach( + overflow -> player.getWorld().dropItemNaturally(player.getLocation(), overflow)); + } + } + } +} diff --git a/src/main/java/studio/magemonkey/fusion/crafting/CraftingResult.java b/src/main/java/studio/magemonkey/fusion/crafting/CraftingResult.java new file mode 100644 index 0000000..74aaace --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/CraftingResult.java @@ -0,0 +1,18 @@ +package studio.magemonkey.fusion.crafting; + +public enum CraftingResult { + SUCCESS, NOT_FOUND, NOT_READY, REQUIREMENTS_NOT_MET, EVENT_CANCELLED, STORAGE_FAILED; + + public void report(org.bukkit.entity.Player player) { + if (this == SUCCESS) return; + String message = switch (this) { + case NOT_FOUND -> "That craft is no longer available. Please refresh the menu."; + case NOT_READY -> "That craft is not finished yet."; + case REQUIREMENTS_NOT_MET -> "You do not meet the requirements for this craft."; + case EVENT_CANCELLED -> "This crafting action was cancelled by a server plugin."; + case STORAGE_FAILED -> "Crafting storage is unavailable. The action could not complete; please try again."; + default -> "Unable to complete crafting."; + }; + player.sendMessage(org.bukkit.ChatColor.RED + message); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/crafting/CraftingRewardService.java b/src/main/java/studio/magemonkey/fusion/crafting/CraftingRewardService.java new file mode 100644 index 0000000..f2cb238 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/CraftingRewardService.java @@ -0,0 +1,41 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import studio.magemonkey.codex.api.DelayedCommand; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.api.FusionAPI; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.util.PlayerUtil; +import java.util.*; + +public final class CraftingRewardService { + public void give(Player player, CraftingTable table, Recipe recipe, List results) { + CraftingCostService items = new CraftingCostService(); + if (!recipe.getResults().hasCommandsOrItems()) { + ItemStack result = recipe.getDivinityRecipeMeta() == null + ? recipe.getSettings().getRecipeItem().getItemStack().clone() + : recipe.getDivinityRecipeMeta().generateItem(); + if (player.hasPermission("fusion.craftedby." + recipe.getName())) { + var meta = result.getItemMeta(); + if (meta != null) { + List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); + lore.add("§f - §eCrafted by: §f" + player.getName()); + meta.setLore(lore); + result.setItemMeta(meta); + } + } + items.giveItems(player, List.of(result)); + } else { + for (RecipeItem result : results) { + ItemStack stack = result.getItemStack(); + if (stack != null) items.giveItems(player, List.of(stack)); + } + DelayedCommand.invoke(Fusion.getInstance(), player, recipe.getResults().getCommands()); + } + long xp = recipe.getResults().getProfessionExp(); + xp += (long) (xp * PlayerUtil.getProfessionExpBonusThroughPermissions(player, table.getName())); + if (xp > 0) FusionAPI.getEventServices().getProfessionService().giveProfessionExp(player, table, xp); + if (recipe.getResults().getVanillaExp() > 0) player.giveExp(recipe.getResults().getVanillaExp()); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/crafting/ManualCraftingService.java b/src/main/java/studio/magemonkey/fusion/crafting/ManualCraftingService.java new file mode 100644 index 0000000..80b049e --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/ManualCraftingService.java @@ -0,0 +1,83 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import studio.magemonkey.fusion.data.recipes.*; + +/** One manual operation per session. Closing the session cancels its owned task. */ +public final class ManualCraftingService { + private final Player player; + private final CraftingTable table; + private final CraftingCostService costs = new CraftingCostService(); + private BukkitTask task; + private CraftingReceipt receipt; + private Recipe recipe; + private long deadline; + private int duration; + private long generation; + + public ManualCraftingService(Player player, CraftingTable table) { this.player = player; this.table = table; } + public boolean isRunning() { return receipt != null; } + public Recipe getRecipe() { return recipe; } + public int getDuration() { return duration; } + public long getRemainingSeconds() { return isRunning() ? Math.max(0, (deadline - System.currentTimeMillis() + 999) / 1000) : 0; } + + public CraftingResult start(Recipe selected, Runnable changed) { + boolean same = recipe != null && recipe.getRecipePath().equals(selected.getRecipePath()); + cancel(); + if (same) { changed.run(); return CraftingResult.SUCCESS; } + if (!CraftingChecks.canCraft(player, table, selected, null)) return CraftingResult.REQUIREMENTS_NOT_MET; + receipt = costs.charge(player, selected); + if (receipt == null) return CraftingResult.REQUIREMENTS_NOT_MET; + recipe = selected; + duration = Math.max(0, (int) Math.round(selected.getCraftingTime() + * (1 - Fusion.getInstance().getPlayerCooldown(player)))); + deadline = System.currentTimeMillis() + duration * 1000L; + long operation = generation; + task = Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> { + if (operation == generation) complete(changed); + }, Math.max(1, duration * 20L)); + changed.run(); + return CraftingResult.SUCCESS; + } + + private void complete(Runnable changed) { + task = null; + if (!isRunning()) return; + if (!player.isOnline()) { cancel(); return; } + Recipe completed = recipe; + try { + var owner = PlayerLoader.getPlayer(player); + if (owner.hasRecipeLimitReached(completed)) { + cancel(); + CraftingResult.REQUIREMENTS_NOT_MET.report(player); + changed.run(); + return; + } + if (completed.getCraftingLimit() > 0) owner.incrementLimit(completed); + } catch (RuntimeException exception) { + cancel(); + CraftingResult.STORAGE_FAILED.report(player); + changed.run(); + return; + } + receipt = null; + recipe = null; + new CraftingRewardService().give(player, table, completed, completed.getResults().getItems()); + changed.run(); + } + + public void cancel() { + generation++; + if (task != null) task.cancel(); + task = null; + CraftingReceipt refund = receipt; + receipt = null; + recipe = null; + if (refund != null) costs.refund(player, refund); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/crafting/QueueCraftingService.java b/src/main/java/studio/magemonkey/fusion/crafting/QueueCraftingService.java new file mode 100644 index 0000000..1a465c7 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/crafting/QueueCraftingService.java @@ -0,0 +1,53 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.entity.Player; +import studio.magemonkey.fusion.api.FusionAPI; +import studio.magemonkey.fusion.data.queue.*; +import studio.magemonkey.fusion.data.recipes.*; + +/** Application actions used by the queue GUI; no inventory slot state. */ +public final class QueueCraftingService { + private final CraftingCostService costs = new CraftingCostService(); + + public CraftingResult enqueue(Player player, CraftingTable table, CraftingQueue queue, Recipe recipe) { + if (!CraftingChecks.canCraft(player, table, recipe, queue)) return CraftingResult.REQUIREMENTS_NOT_MET; + CraftingReceipt receipt = costs.charge(player, recipe); + if (receipt == null) return CraftingResult.REQUIREMENTS_NOT_MET; + QueueItem item = new QueueItem(-1, table.getName(), queue.getCategory(), recipe, System.currentTimeMillis(), 0); + item.setReceipt(receipt); + item.setPaidExpCost(receipt.getExperience()); + boolean added = false; + try { + // Validate encoding before publishing or accepting the craft. + receipt.encode(); + CraftingResult result = FusionAPI.getEventServices().getQueueService().addQueueItemResult(player, table, queue, item); + added = result == CraftingResult.SUCCESS; + QueueProgress.refreshTimes(queue.getQueue()); + return result; + } catch (RuntimeException exception) { + studio.magemonkey.fusion.Fusion.getInstance().getLogger().warning("Cannot enqueue craft: " + exception.getMessage()); + return CraftingResult.STORAGE_FAILED; + } finally { + if (!added && item.getId() == -1) costs.refund(player, receipt); + } + } + + public CraftingResult cancel(Player player, CraftingTable table, CraftingQueue queue, long id) { + QueueItem item = find(queue, id); + if (item == null) return CraftingResult.NOT_FOUND; + return FusionAPI.getEventServices().getQueueService().cancelQueueItemAndReport(player, table, queue, + item, false, true, item.getReceipt() == null ? item.getRecipe().getItemsToTake() : item.getReceipt().getItems()); + } + + public CraftingResult collect(Player player, CraftingTable table, CraftingQueue queue, long id) { + QueueItem item = find(queue, id); + if (item == null) return CraftingResult.NOT_FOUND; + if (!item.isDone()) return CraftingResult.NOT_READY; + return FusionAPI.getEventServices().getQueueService().finishQueueItemAndReport( + player, table, queue, item, item.getRecipe().getResults().getItems()); + } + + public QueueItem find(CraftingQueue queue, long id) { + return queue.getQueue().stream().filter(item -> item.getId() == id).findFirst().orElse(null); + } +} 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 a2fd56e..d6cbd4c 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java @@ -26,13 +26,16 @@ public class FusionPlayer { private final UUID uuid; - private final Map professions = new TreeMap<>(); private Map cachedQueues = new TreeMap<>(); - private Map cachedRecipeLimits = new TreeMap<>(); - @Getter - @Setter - private boolean autoCrafting; + public boolean isAutoCrafting() { + return SQLManager.players().isAutoCrafting(uuid); + } + + public void setAutoCrafting(boolean autoCrafting) { + SQLManager.players().setAutoCrafting(uuid, autoCrafting); + + } // Track whether this player is currently locked for saving (in-memory mirror of DB lock) @Getter @@ -43,14 +46,17 @@ public FusionPlayer(UUID uuid) { this.uuid = uuid; // initialize locked state from DB to reflect current status this.locked = SQLManager.players().isLocked(uuid); - autoCrafting = SQLManager.players().isAutoCrafting(uuid); - for (Profession profession : SQLManager.professions().getProfessions(uuid)) { - professions.put(profession.getName(), profession); - } + cachedQueues = SQLManager.queues().getCraftingQueues(getPlayer()); - cachedRecipeLimits = SQLManager.recipeLimits().getRecipeLimits(uuid); } + private Map professions() { + Map current = new TreeMap<>(); + for (Profession profession : SQLManager.professions().getProfessions(uuid)) { + current.put(profession.getName(), profession); + } + return current; + } public Player getPlayer() { return Bukkit.getPlayer(uuid); } @@ -68,15 +74,12 @@ public PlayerRecipeLimit getRecipeLimit(Recipe recipe) { } public PlayerRecipeLimit getRecipeLimit(String recipePath) { - cachedRecipeLimits.putIfAbsent(recipePath, new PlayerRecipeLimit(recipePath, 0, -1)); - return cachedRecipeLimits.get(recipePath); + return SQLManager.recipeLimits().getRecipeLimit(uuid, recipePath); } public void incrementLimit(Recipe recipe) { - getRecipeLimit(recipe).incrementLimit(1); - if (recipe.getCraftingLimitCooldown() > 0) { - getRecipeLimit(recipe).updateCooldown(recipe.getCraftingLimitCooldown()); - } + SQLManager.recipeLimits().incrementLimit(uuid, recipe.getRecipePath(), 1, + recipe.getCraftingLimitCooldown()); } public boolean hasRecipeLimitReached(Recipe recipe) { @@ -86,8 +89,9 @@ public boolean hasRecipeLimitReached(Recipe recipe) { public long getExperience(String profession) { long experience = 0; - if (professions.containsKey(profession)) { - experience = (int) professions.get(profession).getExp(); + Profession current = getProfession(profession); + if (current != null) { + experience = current.getExp(); } return experience; } @@ -102,8 +106,9 @@ public long getExperience(CraftingTable table) { public int getLevel(String profession) { int level = 0; - if (professions.containsKey(profession)) { - level = professions.get(profession).getLevel(); + Profession current = getProfession(profession); + if (current != null) { + level = current.getLevel(); } return level; } @@ -118,7 +123,7 @@ public int getLevel(CraftingTable table) { @Nullable public Profession getProfession(String profession) { - return professions.get(profession); + return SQLManager.professions().getProfession(uuid, profession); } @Nullable @@ -127,7 +132,8 @@ public Profession getProfession(CraftingTable table) { } public void removeProfession(String profession) { - professions.remove(profession); + SQLManager.professions().removeProfession(uuid.toString(), profession); + } public void removeProfession(Profession profession) { @@ -139,11 +145,12 @@ public void removeProfession(CraftingTable table) { } public void addProfession(Profession profession) { - professions.put(profession.getName(), profession); + SQLManager.professions().setProfession(uuid, profession); + } public boolean hasProfession(String profession) { - return professions.containsKey(profession); + return getProfession(profession) != null; } public boolean hasProfession(Profession profession) { @@ -155,19 +162,20 @@ public boolean hasProfession(CraftingTable table) { } public Collection getJoinedProfessions() { - return professions.values().stream().filter(Profession::isJoined).collect(Collectors.toList()); + return professions().values().stream().filter(Profession::isJoined).collect(Collectors.toList()); } public Collection getMasteredProfessions() { - return professions.values().stream().filter(Profession::isMastered).collect(Collectors.toList()); + return professions().values().stream().filter(Profession::isMastered).collect(Collectors.toList()); } public Collection getProfessions() { - return professions.values(); + return professions().values(); } public boolean hasMastered(String profession) { - return professions.containsKey(profession) && professions.get(profession).isMastered(); + Profession current = getProfession(profession); + return current != null && current.isMastered(); } public boolean hasMastered(Profession profession) { @@ -179,7 +187,8 @@ public boolean hasMastered(CraftingTable table) { } public boolean hasJoined(String profession) { - return professions.containsKey(profession) && professions.get(profession).isJoined(); + Profession current = getProfession(profession); + return current != null && current.isJoined(); } public boolean hasJoined(Profession profession) { @@ -191,8 +200,9 @@ public boolean hasJoined(CraftingTable table) { } public void setMastered(String profession, boolean mastered) { - if (professions.containsKey(profession)) { - professions.get(profession).setMastered(mastered); + Profession current = getProfession(profession); + if (current != null) { + current.setMastered(mastered); } } @@ -205,8 +215,9 @@ public void setMastered(CraftingTable table, boolean mastered) { } public void setJoined(String profession, boolean joined) { - if (professions.containsKey(profession)) { - professions.get(profession).setJoined(joined); + Profession current = getProfession(profession); + if (current != null) { + current.setJoined(joined); } } @@ -219,8 +230,9 @@ public void setJoined(CraftingTable table, boolean joined) { } public void addExperience(String profession, long experience) { - if (professions.containsKey(profession)) { - professions.get(profession).addExp(experience); + Profession current = getProfession(profession); + if (current != null) { + current.addExp(experience); } } @@ -233,8 +245,9 @@ public void addExp(CraftingTable table, long experience) { } public void setExperience(String profession, long experience) { - if (professions.containsKey(profession)) { - professions.get(profession).setExp(experience); + Profession current = getProfession(profession); + if (current != null) { + current.setExp(experience); } } @@ -247,8 +260,9 @@ public void setExp(CraftingTable table, long experience) { } public void removeExperience(String profession, long experience) { - if (professions.containsKey(profession)) { - professions.get(profession).removeExp(experience); + Profession current = getProfession(profession); + if (current != null) { + current.removeExp(experience); } } @@ -261,8 +275,9 @@ public void removeExp(CraftingTable table, long experience) { } public void resetExperience(String profession) { - if (professions.containsKey(profession)) { - professions.get(profession).resetExp(); + Profession current = getProfession(profession); + if (current != null) { + current.resetExp(); } } @@ -275,8 +290,9 @@ public void resetExp(CraftingTable table) { } public void resetMastered(String profession) { - if (professions.containsKey(profession)) { - professions.get(profession).resetMastered(); + Profession current = getProfession(profession); + if (current != null) { + current.resetMastered(); } } @@ -289,8 +305,9 @@ public void resetMastered(CraftingTable table) { } public void resetJoined(String profession) { - if (professions.containsKey(profession)) { - professions.get(profession).resetJoined(); + Profession current = getProfession(profession); + if (current != null) { + current.resetJoined(); } } @@ -303,8 +320,9 @@ public void resetJoined(CraftingTable table) { } public void reset(String profession) { - if (professions.containsKey(profession)) { - professions.get(profession).reset(); + Profession current = getProfession(profession); + if (current != null) { + current.reset(); } } @@ -368,38 +386,19 @@ public void save() { } public void save(boolean clearCaches) { - // set DB lock and in-memory lock - SQLManager.players().setLocked(uuid, true); + // Finish before unloading: a new session must never race an old async snapshot. this.locked = true; - - Map queuesToSave = new TreeMap<>(cachedQueues); - Map recipeLimitsToSave = new TreeMap<>(cachedRecipeLimits); - - if (clearCaches) { - cachedQueues.clear(); - cachedRecipeLimits.clear(); - } - - Bukkit.getScheduler().runTaskAsynchronously(Fusion.getInstance(), () -> { - SQLManager.players().setAutoCrafting(uuid, autoCrafting); - for (Profession profession : professions.values()) { - SQLManager.professions().setProfession(uuid, profession); - } - for (CraftingQueue queue : queuesToSave.values()) { + try { + for (CraftingQueue queue : cachedQueues.values()) { + if (clearCaches) queue.cancelTask(); + long now = System.currentTimeMillis(); + queue.getQueue().forEach(item -> item.setTimestamp(now)); SQLManager.queues().saveCraftingQueue(queue); } - SQLManager.recipeLimits().saveRecipeLimits(uuid, recipeLimitsToSave); - - /* - In case of race conditions we wait a bit before unlocking the player. Not required but just to be safe. - try { - Thread.sleep(250); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - */ + if (clearCaches) cachedQueues.clear(); + } finally { SQLManager.players().setLocked(uuid, false); this.locked = false; - }); + } } } diff --git a/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java b/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java index 8c21d38..31ffcea 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java @@ -68,6 +68,7 @@ public static FusionPlayer getPlayerBlocking(Player player, long timeoutMs) { } public static void loadPlayer(Player player) { + unloadPlayer(player); cachedPlayers.put(player.getUniqueId(), new FusionPlayer(player.getUniqueId())); } diff --git a/src/main/java/studio/magemonkey/fusion/data/player/PlayerRecipeLimit.java b/src/main/java/studio/magemonkey/fusion/data/player/PlayerRecipeLimit.java index ea6ff65..0f8bb71 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/PlayerRecipeLimit.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/PlayerRecipeLimit.java @@ -1,6 +1,8 @@ package studio.magemonkey.fusion.data.player; import lombok.Getter; +import studio.magemonkey.fusion.cfg.sql.SQLManager; +import java.util.UUID; @Getter public class PlayerRecipeLimit { @@ -8,19 +10,28 @@ public class PlayerRecipeLimit { private final String recipeName; private int limit; private long cooldownTimestamp; + private final UUID owner; public PlayerRecipeLimit(String recipeName, int limit, long cooldownTimestamp) { + this(null, recipeName, limit, cooldownTimestamp); + } + + public PlayerRecipeLimit(UUID owner, String recipeName, int limit, long cooldownTimestamp) { + this.owner = owner; this.recipeName = recipeName; this.limit = limit; this.cooldownTimestamp = cooldownTimestamp; } public void incrementLimit(int amount) { + if (owner != null) SQLManager.recipeLimits().incrementLimit(owner, recipeName, amount, 0); limit += amount; } public void updateCooldown(int seconds) { - cooldownTimestamp = System.currentTimeMillis() + (seconds * 1000L); + long timestamp = System.currentTimeMillis() + (seconds * 1000L); + if (owner != null) SQLManager.recipeLimits().updateCooldown(owner, recipeName, timestamp); + cooldownTimestamp = timestamp; } public boolean hasCooldown() { @@ -28,6 +39,7 @@ public boolean hasCooldown() { } public void resetLimit() { + if (owner != null) SQLManager.recipeLimits().resetLimit(owner, recipeName); limit = 0; cooldownTimestamp = -1; } diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/Profession.java b/src/main/java/studio/magemonkey/fusion/data/professions/Profession.java index 3ddb3c3..210e165 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/Profession.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/Profession.java @@ -14,11 +14,8 @@ public class Profession { private final long id; private final UUID uuid; private final String name; - @Setter private long exp; - @Setter private boolean mastered; - @Setter private boolean joined; public Profession(long id, UUID uuid, String name, long exp, boolean mastered, boolean joined) { @@ -31,23 +28,24 @@ public Profession(long id, UUID uuid, String name, long exp, boolean mastered, b } public void addExp(long exp) { + SQLManager.professions().incrementExperience(this, exp); this.exp += exp; } public void removeExp(long exp) { - this.exp -= exp; + addExp(-exp); } public void resetExp() { - this.exp = 0; + setExp(0); } public void resetMastered() { - this.mastered = false; + setMastered(false); } public void resetJoined() { - this.joined = false; + setJoined(false); } public void reset() { @@ -60,6 +58,21 @@ public void update() { SQLManager.professions().setProfession(uuid, this); } + public void setExp(long exp) { + SQLManager.professions().setValue(this, "Experience", exp); + this.exp = exp; + } + + public void setMastered(boolean mastered) { + SQLManager.professions().setValue(this, "Mastered", mastered); + this.mastered = mastered; + } + + public void setJoined(boolean joined) { + SQLManager.professions().setValue(this, "Joined", joined); + this.joined = joined; + } + public int getLevel() { return ProfessionsCfg.getGuiMap().containsKey(name) ? ProfessionsCfg.getTable(name).getLevelFunction().getLevel(exp) : 0; } 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 9b537ad..5a3a743 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 @@ -18,6 +18,8 @@ import java.util.stream.Collectors; public class InventoryPattern implements ConfigurationSerializable { + @Getter + private final Map roles = new HashMap<>(); @Getter private final String[] pattern; // - for ingredients, = for result. @Getter @@ -37,6 +39,12 @@ public InventoryPattern(Map map) { DeserializationWorker dw = DeserializationWorker.start(map); List temp = dw.getStringList("pattern"); this.pattern = temp.toArray(new String[0]); + Map configuredRoles = dw.getSection("roles", new HashMap<>()); + configuredRoles.forEach((symbol, role) -> { + if (symbol.length() != 1) throw new IllegalArgumentException("Pattern role keys must be single characters: " + symbol); + roles.put(symbol.charAt(0), studio.magemonkey.fusion.gui.recipe.SlotRole.valueOf( + role.toString().toUpperCase(java.util.Locale.ROOT).replace('-', '_'))); + }); this.items = new HashMap<>(); DeserializationWorker itemsTemp = DeserializationWorker.start(dw.getSection("items", new HashMap<>(2))); for (String entry : itemsTemp.getMap().keySet()) { @@ -119,6 +127,8 @@ public List> getItemsAsList() { public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE).appendSuper(super.toString()) .append("pattern", this.pattern) + .append("roles", roles.entrySet().stream().collect(Collectors.toMap( + entry -> entry.getKey().toString(), entry -> entry.getValue().name().toLowerCase(java.util.Locale.ROOT).replace('_', '-')))) .append("items", this.items) .toString(); } @@ -189,7 +199,7 @@ public void clear() { StringBuilder sb = new StringBuilder(pattern[i]); for (int j = 0; j < sb.length(); j++) { char c = sb.charAt(j); - if (c != 'o' && c != '-' && c != '<' && c != '>' && c != '{' && c != '}' && c != 'f') { + if (!roles.containsKey(c) && c != 'o' && c != '-' && c != '<' && c != '>' && c != '{' && c != '}' && c != 'f') { sb.setCharAt(j, 'f'); } } @@ -221,6 +231,7 @@ public static InventoryPattern copy(InventoryPattern pattern) { InventoryPattern _pattern = new InventoryPattern(patternCopy, new HashMap<>(itemsCopy)); _pattern.commands.putAll(pattern.commands); + _pattern.roles.putAll(pattern.roles); _pattern.closeOnClickSlots.addAll(pattern.closeOnClickSlots); return _pattern; } 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 d334736..3f307b3 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingQueue.java @@ -19,6 +19,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.HashSet; +import java.util.Set; @Getter public class CraftingQueue { @@ -27,8 +29,12 @@ public class CraftingQueue { private final String profession; private final Category category; private final List queue = new ArrayList<>(); + /** Legacy API only; live GUI bindings belong to each open view. */ + @Deprecated private final HashMap queuedItems; private final BukkitTask queueTask; + // Only failed/unacknowledged writes are retained for retry. + private final Set pendingCheckpoints = new HashSet<>(); @Getter private int visualRemainingTotalTime = 0; @@ -56,7 +62,7 @@ public CraftingQueue(Player player, String profession, Category category) { .findFirst() .orElse(null); if (current != null) { - int offlineSeconds = (int) ((now - current.getTimestamp()) / 1000L); + long offlineSeconds = Math.max(0L, (now - current.getTimestamp()) / 1000L); // apply offline progress sequentially for (QueueItem item : queue) { if (offlineSeconds <= 0) { @@ -65,16 +71,18 @@ public CraftingQueue(Player player, String profession, Category category) { if (item.isDone()) { continue; } - int remaining = item.getRecipe().getCraftingTime() - item.getSavedSeconds(); - int apply = Math.min(offlineSeconds, remaining); + int remaining = item.getCraftingTime() - item.getSavedSeconds(); + int apply = (int) Math.min(offlineSeconds, remaining); item.progressOffline(apply); offlineSeconds -= apply; } } // normalize timestamps after applying offline progress queue.forEach(item -> item.setTimestamp(now)); + SQLManager.queues().saveCraftingQueue(this); } + visualRemainingTotalTime = QueueProgress.refreshTimes(queue); // Assign the queue and update the icons queue.forEach(entry -> { entry.setCraftinQueue(this); @@ -89,17 +97,34 @@ public void run() { cancel(); return; } - visualRemainingTotalTime = 0; - queue.forEach(item -> { - if (!item.isDone()) { - visualRemainingTotalTime += (item.getRecipe().getCraftingTime() - item.getSavedSeconds()); - item.update(); - } - }); + tick(System.currentTimeMillis()); } }.runTaskTimer(Fusion.getInstance(), 0, 20L); } + private long lastTick = System.currentTimeMillis(); + + public void tick(long now) { + long seconds = Math.max(0, (now - lastTick) / 1000L); + if (seconds == 0) return; + lastTick += seconds * 1000L; + java.util.Map before = new java.util.IdentityHashMap<>(); + queue.forEach(item -> before.put(item, item.getSavedSeconds())); + QueueProgress.advance(queue, seconds); + visualRemainingTotalTime = QueueProgress.refreshTimes(queue); + queue.forEach(item -> { + if (item.getSavedSeconds() != before.get(item)) { + item.setTimestamp(now); + pendingCheckpoints.add(item); + } + }); + queue.stream().filter(item -> !item.isDone()).findFirst().ifPresent(item -> { + item.setTimestamp(now); + pendingCheckpoints.add(item); + }); + pendingCheckpoints.retainAll(queue); + pendingCheckpoints.removeIf(item -> SQLManager.queues().setQueueItem(player.getUniqueId(), item)); + } public void addRecipe(Recipe recipe) { addRecipe(recipe, 0); } @@ -143,7 +168,7 @@ public void removeRecipe(QueueItem item, boolean refund) { item, !refund, refund, - refund ? item.getRecipe().getItemsToTake() : List.of()); + refund ? (item.getReceipt() == null ? item.getRecipe().getItemsToTake() : item.getReceipt().getItems()) : List.of()); } public void cancelTask() { diff --git a/src/main/java/studio/magemonkey/fusion/data/queue/CraftingReceipt.java b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingReceipt.java new file mode 100644 index 0000000..29e1e77 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/data/queue/CraftingReceipt.java @@ -0,0 +1,54 @@ +package studio.magemonkey.fusion.data.queue; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.io.BukkitObjectInputStream; +import org.bukkit.util.io.BukkitObjectOutputStream; +import java.io.*; +import java.util.*; + +/** Immutable snapshot of the resources actually charged for one execution. */ +public final class CraftingReceipt { + private final List items; + private final int experience; + private final double money; + + public CraftingReceipt(Collection items, int experience, double money) { + this.items = items.stream().filter(Objects::nonNull).map(ItemStack::clone).toList(); + this.experience = experience; + this.money = money; + } + + public List getItems() { return items.stream().map(ItemStack::clone).toList(); } + public int getExperience() { return experience; } + public double getMoney() { return money; } + + public String encode() { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + BukkitObjectOutputStream out = new BukkitObjectOutputStream(bytes)) { + out.writeInt(1); + out.writeInt(experience); + out.writeDouble(money); + out.writeInt(items.size()); + for (ItemStack item : items) out.writeObject(item); + out.flush(); + return Base64.getEncoder().encodeToString(bytes.toByteArray()); + } catch (IOException e) { throw new IllegalStateException("Cannot encode crafting receipt", e); } + } + + public static CraftingReceipt decode(String encoded) { + if (encoded == null || encoded.isEmpty()) return null; // legacy row + try (BukkitObjectInputStream in = new BukkitObjectInputStream( + new ByteArrayInputStream(Base64.getDecoder().decode(encoded)))) { + if (in.readInt() != 1) throw new IOException("Unknown receipt version"); + int experience = in.readInt(); + double money = in.readDouble(); + int size = in.readInt(); + if (size < 0 || size > 10000) throw new IOException("Invalid receipt size"); + List items = new ArrayList<>(); + for (int i = 0; i < size; i++) items.add((ItemStack) in.readObject()); + return new CraftingReceipt(items, experience, money); + } catch (IOException | ClassNotFoundException | IllegalArgumentException e) { + throw new IllegalStateException("Cannot decode crafting receipt; refusing an inaccurate refund", e); + } + } +} 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 0d7d412..4fb66de 100644 --- a/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/queue/QueueItem.java @@ -14,6 +14,7 @@ @Getter public class QueueItem { + @Setter private long id; private String profession; private Category category; @@ -23,11 +24,16 @@ public class QueueItem { private long timestamp; private boolean done; private int savedSeconds; + // The duration charged at enqueue time, including player-specific modifiers. + private int craftingTime; /** Experience charged when this queue item was created, for cancellation refunds. */ @Setter private int paidExpCost; + @Setter + private CraftingReceipt receipt; + private boolean isRunning = false; private CraftingQueue craftingQueue; @@ -53,6 +59,7 @@ public QueueItem(long id, this.profession = profession; this.category = category; this.recipe = Objects.requireNonNull(recipe, "recipe"); + this.craftingTime = recipe.getCraftingTime(); this.icon = icon; this.timestamp = timestamp; this.done = done; @@ -72,9 +79,18 @@ public QueueItem(int id, this.profession = profession; this.category = category; this.recipe = recipe; + this.craftingTime = recipe.getCraftingTime(); this.timestamp = timestamp; this.savedSeconds = savedSeconds; this.visualRemainingItemTime = recipe.getCraftingTime() - savedSeconds; + this.done = savedSeconds >= craftingTime; + } + + public void restoreCraftingTime(int craftingTime) { + this.craftingTime = Math.max(0, craftingTime); + this.savedSeconds = Math.min(Math.max(0, savedSeconds), this.craftingTime); + this.done = savedSeconds >= this.craftingTime; + this.visualRemainingItemTime = this.craftingTime - savedSeconds; } public QueueItem(int id, @@ -88,45 +104,25 @@ public void setCraftinQueue(CraftingQueue craftingQueue) { this.craftingQueue = craftingQueue; } + /** Compatibility refresh only; the queue service owns advancement. */ public void update() { - if (isDone()) return; - if (this.craftingQueue != null) { - this.visualRemainingItemTime = craftingQueue.getVisualRemainingTotalTime(); - 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; - } - // 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); - } + if (craftingQueue != null) QueueProgress.refreshTimes(craftingQueue.getQueue()); + updateIcon(); } + public int getRemainingSeconds() { + return done ? 0 : Math.max(0, craftingTime - savedSeconds); + } + + public void setVisualRemainingItemTime(int seconds) { + visualRemainingItemTime = Math.max(0, seconds); + } public void updateIcon() { this.icon = ProfessionsCfg.getQueueItem(profession, this); } public void markDone() { + this.savedSeconds = craftingTime; this.done = true; this.visualRemainingItemTime = 0; } @@ -139,16 +135,16 @@ public void progressOffline(int offlineSeconds) { if (done || offlineSeconds <= 0) { return; } - int remaining = recipe.getCraftingTime() - savedSeconds; + int remaining = craftingTime - savedSeconds; if (offlineSeconds >= remaining) { // item has finished offline - savedSeconds = recipe.getCraftingTime(); + savedSeconds = craftingTime; done = true; } else { // item partially progressed offline savedSeconds += offlineSeconds; } // update the remaining time for the UI - visualRemainingItemTime = recipe.getCraftingTime() - savedSeconds; + visualRemainingItemTime = craftingTime - savedSeconds; } } diff --git a/src/main/java/studio/magemonkey/fusion/data/queue/QueueProgress.java b/src/main/java/studio/magemonkey/fusion/data/queue/QueueProgress.java new file mode 100644 index 0000000..94819f4 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/data/queue/QueueProgress.java @@ -0,0 +1,29 @@ +package studio.magemonkey.fusion.data.queue; + +import java.util.List; + +/** Pure queue timing. Presentation never determines which craft may advance. */ +public final class QueueProgress { + private QueueProgress() { } + + public static void advance(List items, long seconds) { + long remaining = Math.max(0, seconds); + for (QueueItem item : items) { + if (item.isDone()) continue; + int applied = (int) Math.min(remaining, item.getRemainingSeconds()); + item.progressOffline(applied); + remaining -= applied; + if (remaining == 0) break; + } + refreshTimes(items); + } + + public static int refreshTimes(List items) { + long total = 0; + for (QueueItem item : items) { + total += item.getRemainingSeconds(); + item.setVisualRemainingItemTime(item.isDone() ? 0 : (int) Math.min(Integer.MAX_VALUE, total)); + } + return (int) Math.min(Integer.MAX_VALUE, total); + } +} 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 f32f5b2..a012798 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java @@ -56,6 +56,13 @@ public static CalculatedRecipe create(Recipe recipe, Map invCounts, Player player, CraftingTable craftingTable) throws InvalidPatternItemException { + return create(recipe, invCounts, player, craftingTable, + studio.magemonkey.fusion.gui.recipe.RecipeViewSnapshot.capture(player, craftingTable)); + } + + public static CalculatedRecipe create(Recipe recipe, Map invCounts, + Player player, CraftingTable craftingTable, + studio.magemonkey.fusion.gui.recipe.RecipeViewSnapshot state) throws InvalidPatternItemException { try { StringBuilder lore = new StringBuilder(512); @@ -93,13 +100,10 @@ public static CalculatedRecipe create(Recipe recipe, // 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 (!state.economyAvailable() || state.balance() < recipe.getConditions().getMoneyCost()) { canCraft = false; } - double balance = (CodexEngine.get().getVault() == null) - ? 0.0 - : CodexEngine.get().getVault().getBalance(player); + double balance = state.balance(); moneyLine = CraftingRequirementsCfg.getMoney( "recipes", (int) balance, @@ -110,7 +114,7 @@ public static CalculatedRecipe create(Recipe recipe, // 4) XP cost String expLine = null; if (recipe.getConditions().getExpCost() != 0) { - int totalExp = ExperienceManager.getTotalExperience(player); + int totalExp = state.experience(); if (totalExp < recipe.getConditions().getExpCost()) { canCraft = false; } @@ -124,7 +128,7 @@ public static CalculatedRecipe create(Recipe recipe, // 5) Profession level String levelsLine = null; if (recipe.getConditions().getProfessionLevel() != 0) { - int profLevel = recipe.getTable().getLevelFunction().getLevel(player); + int profLevel = state.level(); if (profLevel < recipe.getConditions().getProfessionLevel()) { canCraft = false; } @@ -138,8 +142,7 @@ public static CalculatedRecipe create(Recipe recipe, // 6) Mastery String masteryLine = null; if (recipe.getConditions().isMastery()) { - boolean hasMastery = PlayerLoader.getPlayer(player) - .hasMastered(craftingTable.getName()); + boolean hasMastery = state.mastered(); if (!hasMastery) { canCraft = false; } @@ -153,15 +156,7 @@ public static CalculatedRecipe create(Recipe recipe, // 7) Crafting limit String limitLine = null; if (recipe.getCraftingLimit() > 0) { - PlayerRecipeLimit limit = PlayerLoader.getPlayer(player).getRecipeLimit(recipe); - if (limit.getLimit() > 0 && - limit.getCooldownTimestamp() > 0 && - !limit.hasCooldown()) { - limit.resetLimit(); - Bukkit.getConsoleSender().sendMessage( - "§aResetting limit for " + player.getName() + " on " + recipe.getRecipePath() - ); - } + PlayerRecipeLimit limit = state.limit(recipe.getRecipePath()); if (limit.getLimit() >= recipe.getCraftingLimit()) { canCraft = false; } diff --git a/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java b/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java index 1649e0b..19d287b 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java @@ -104,14 +104,14 @@ public void reloadCategories() { : CodexEngine.get().getVault().getBalance(player)) }); - allCategories.forEach((category) -> allCategoriesMap.putIfAbsent(category.getName(), new RecipeGui(player, table, category))); + allCategories.forEach((category) -> allCategoriesMap.putIfAbsent(category.getName(), RecipeGuiFactory.create(player, table, category))); for (int k = (page * pageSize), e = Math.min(slots.length, allCategoryArray.length); (k < allCategoryArray.length) && (i < e); k++, i++) { Category category = allCategoryArray[k]; int slot = slots[i]; - this.categories.put(slot, new RecipeGui(player, table, category)); + this.categories.put(slot, RecipeGuiFactory.create(player, table, category)); this.inventory.setItem(slot, category.getDisplayIcon()); } diff --git a/src/main/java/studio/magemonkey/fusion/gui/ManualRecipeGUI.java b/src/main/java/studio/magemonkey/fusion/gui/ManualRecipeGUI.java new file mode 100644 index 0000000..3b861ad --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/ManualRecipeGUI.java @@ -0,0 +1,38 @@ +package studio.magemonkey.fusion.gui; + +import org.bukkit.Bukkit; +import org.bukkit.boss.*; +import org.bukkit.entity.Player; +import studio.magemonkey.fusion.cfg.CraftingRequirementsCfg; +import studio.magemonkey.fusion.crafting.ManualCraftingService; +import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.gui.recipe.SlotRole; + +public final class ManualRecipeGUI extends RecipeGui { + private final ManualCraftingService service; + private BossBar bar; + public ManualRecipeGUI(Player player, CraftingTable table, Category category) { + super(player, table, category); + service = new ManualCraftingService(player, table); + } + @Override protected void onCraft(Recipe recipe, boolean bulk) { + service.start(recipe, this::reloadRecipesTask).report(player); + } + @Override protected void renderDynamic() { + // Queue placeholders in existing manual-mode patterns are decoration, not craft state. + for (int slot : layout.slots(SlotRole.QUEUE_LIST)) renderer.set(inventory, slot, table.getFillItem()); + for (int slot : layout.slots(SlotRole.PREVIOUS_QUEUE_PAGE)) renderer.set(inventory, slot, table.getFillItem()); + for (int slot : layout.slots(SlotRole.NEXT_QUEUE_PAGE)) renderer.set(inventory, slot, table.getFillItem()); + if (!service.isRunning()) { removeBar(); return; } + if (bar == null) { + bar = Bukkit.createBossBar("", BarColor.BLUE, BarStyle.SOLID); + bar.addPlayer(player); + } + bar.setTitle(CraftingRequirementsCfg.getBossBarTitle(service.getRecipe().getSettings().getRecipeItem().getItemStack())); + bar.setProgress(service.getDuration() == 0 ? 1 : Math.min(1, Math.max(0, + 1 - (double) service.getRemainingSeconds() / service.getDuration()))); + } + @Override protected void onClose() { service.cancel(); removeBar(); } + private void removeBar() { if (bar != null) bar.removeAll(); bar = null; } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java b/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java index 4d92521..3bb4a24 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java +++ b/src/main/java/studio/magemonkey/fusion/gui/ProfessionGuiRegistry.java @@ -32,7 +32,7 @@ public void open(Player player) { categoryGuis.put(player.getUniqueId(), new CategoryGui(player, table)); categoryGuis.get(player.getUniqueId()).open(player); } else { - RecipeGui gui = new RecipeGui(player, table, new Category("master", "PAPER", table.getRecipePattern(), 1)); + RecipeGui gui = RecipeGuiFactory.create(player, table, new Category("master", "PAPER", table.getRecipePattern(), 1)); recipeGuis.put(player.getUniqueId(), gui); recipeGuis.get(player.getUniqueId()).open(player); @@ -77,7 +77,7 @@ public void closeAll() { } public static void clearLatestRecipeGui() { - + for (RecipeGui gui : List.copyOf(latestRecipeGui.values())) gui.close(gui.getPlayer(), gui.getInventory()); latestRecipeGui.clear(); } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/QueuedRecipeGUI.java b/src/main/java/studio/magemonkey/fusion/gui/QueuedRecipeGUI.java new file mode 100644 index 0000000..419c7cf --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/QueuedRecipeGUI.java @@ -0,0 +1,78 @@ +package studio.magemonkey.fusion.gui; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import studio.magemonkey.codex.util.messages.MessageData; +import studio.magemonkey.fusion.cfg.ProfessionsCfg; +import studio.magemonkey.fusion.crafting.*; +import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.data.queue.*; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.gui.recipe.SlotRole; +import java.util.*; + +public final class QueuedRecipeGUI extends RecipeGui { + private final QueueCraftingService service = new QueueCraftingService(); + private final Map queueBindings = new HashMap<>(); + private CraftingQueue queue; + private int queuePage; + + public QueuedRecipeGUI(Player player, CraftingTable table, Category category) { super(player, table, category); } + @Override protected void onOpen() { + queue = PlayerLoader.getPlayer(player).getQueue(table.getName(), category); + } + @Override protected void onClose() { queueBindings.clear(); queue = null; } + @Override protected void onCraft(Recipe recipe, boolean bulk) { + int maximum = bulk ? 1024 : 1; + for (int i = 0; i < maximum; i++) { + CraftingResult result = service.enqueue(player, table, queue, recipe); + if (result != CraftingResult.SUCCESS) { + if (i == 0) result.report(player); + break; + } + } + } + @Override protected void renderDynamic() { + if (queue == null) return; + QueueProgress.refreshTimes(queue.getQueue()); + List items = List.copyOf(queue.getQueue()); + List slots = layout.slots(SlotRole.QUEUE_LIST); + queuePage = clampPage(queuePage, items.size(), slots.size()); + queueBindings.clear(); + for (int offset = 0; offset < slots.size(); offset++) { + int index = queuePage * slots.size() + offset; + int slot = slots.get(offset); + if (index < items.size()) { + QueueItem item = items.get(index); + queueBindings.put(slot, item.getId()); + renderer.set(inventory, slot, ProfessionsCfg.getQueueItem(name, item)); + } else renderer.set(inventory, slot, ProfessionsCfg.getQueueSlot(name)); + } + for (int slot : layout.slots(SlotRole.PREVIOUS_QUEUE_PAGE)) + renderer.set(inventory, slot, queuePage == 0 ? table.getFillItem() : decorations.get(layout.symbol(slot))); + for (int slot : layout.slots(SlotRole.NEXT_QUEUE_PAGE)) + renderer.set(inventory, slot, queuePage >= clampPage(Integer.MAX_VALUE, items.size(), slots.size()) + ? table.getFillItem() : decorations.get(layout.symbol(slot))); + } + @Override protected void onModeClick(int slot, InventoryClickEvent event) { + if (layout.role(slot) == SlotRole.PREVIOUS_QUEUE_PAGE) { queuePage = Math.max(0, queuePage - 1); return; } + if (layout.role(slot) == SlotRole.NEXT_QUEUE_PAGE) { queuePage++; return; } + Long id = queueBindings.get(slot); + if (id == null) return; + QueueItem item = service.find(queue, id); + if (item == null) { CraftingResult.NOT_FOUND.report(player); return; } + if (!item.isDone()) service.cancel(player, table, queue, id).report(player); + else if (event.isRightClick()) { + for (QueueItem completed : List.copyOf(queue.getQueue())) + if (completed.isDone()) service.collect(player, table, queue, completed.getId()).report(player); + } else service.collect(player, table, queue, id).report(player); + } + @Override protected MessageData[] viewData() { + List data = new ArrayList<>(Arrays.asList(super.viewData())); + data.add(new MessageData("queue_done", queue == null ? 0 : queue.getQueue().stream().filter(QueueItem::isDone).count())); + data.add(new MessageData("queue_size", queue == null ? 0 : queue.getQueue().size())); + data.add(new MessageData("queue_time", queue == null ? 0 : QueueProgress.refreshTimes(queue.getQueue()))); + return data.toArray(MessageData[]::new); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java index 8cafebd..bc4ec4f 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java @@ -1,1327 +1,198 @@ package studio.magemonkey.fusion.gui; import lombok.Getter; -import lombok.Setter; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.Material; -import org.bukkit.boss.BarColor; -import org.bukkit.boss.BarFlag; -import org.bukkit.boss.BarStyle; -import org.bukkit.boss.BossBar; import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; -import org.bukkit.event.Event; -import org.bukkit.event.Listener; -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.inventory.*; import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.PlayerInventory; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; -import studio.magemonkey.codex.CodexEngine; import studio.magemonkey.codex.api.DelayedCommand; import studio.magemonkey.codex.api.Replacer; import studio.magemonkey.codex.util.ItemUtils; 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.CraftingRequirementsCfg; import studio.magemonkey.fusion.cfg.ProfessionsCfg; -import studio.magemonkey.fusion.data.player.PlayerLoader; -import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; -import studio.magemonkey.fusion.data.professions.pattern.Category; -import studio.magemonkey.fusion.data.professions.pattern.InventoryPattern; -import studio.magemonkey.fusion.data.queue.CraftingQueue; -import studio.magemonkey.fusion.data.queue.QueueItem; -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.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.hook.VaultHook; +import studio.magemonkey.fusion.crafting.CraftingChecks; +import studio.magemonkey.fusion.data.professions.pattern.*; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.gui.recipe.*; import studio.magemonkey.fusion.util.ChatUT; -import studio.magemonkey.fusion.util.ExperienceManager; -import studio.magemonkey.fusion.util.PlayerUtil; - import java.util.*; -import java.util.stream.Collectors; +/** Shared recipe browsing and lifecycle only. Crafting belongs to the mode services. */ @Getter -public class RecipeGui implements Listener { - - private final Player player; - private final CraftingTable table; - @Getter - protected final String name; - @Getter - private final String inventoryName; - private final Category category; - private InventoryPattern pattern; - private final HashMap recipes; - - private int page = 0; - private int nextPage; - private int prevPage; - private boolean isLoaded = false; - - /* Queue Crafting Mode */ - private int queuePage = 0; - 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; - private BukkitTask barTask; - private BossBar bar; - private final Collection refund = new ArrayList<>(); - private ItemStack previousCursor; - private boolean craftingSuccess = true; - private Recipe craftingRecipe = null; - - - /* Inventory Slot Handling */ - @Getter - @Setter - private Inventory inventory; - private Slot[] slots; - private final ArrayList resultSlots = new ArrayList<>(20); - 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) { +public abstract class RecipeGui { + protected final Player player; + protected final CraftingTable table; + protected final Category category; + protected final String name; + protected final String inventoryName; + protected Inventory inventory; + protected InventoryPattern pattern; + protected CompiledPattern layout; + protected final Map recipes = new HashMap<>(); + protected final SlotRenderer renderer = new SlotRenderer(); + private final GuiUpdateCoordinator updates; + protected Map decorations; + private int page; + private int recipeCount; + private boolean opened; + private RecipeViewSnapshot renderSnapshot; + + protected RecipeGui(Player player, CraftingTable table, Category category) { this.player = player; this.table = table; this.name = table.getName(); this.inventoryName = ChatUT.hexString(table.getInventoryName()); - this.recipes = new HashMap<>(20); - this.category = category != null ? category : new Category("master", "PAPER", this.table.getRecipePattern(), 1); - if (this.category.getPattern() == null) - this.category.setPattern(table.getRecipePattern()); - + this.category = category != null ? category : new Category("master", "PAPER", table.getRecipePattern(), 1); if (this.category.getName().equals("master")) { - this.category.getRecipes().addAll(this.table.getRecipes().values()); + this.category.getRecipes().clear(); + this.category.getRecipes().addAll(table.getRecipes().values()); } + updates = new GuiUpdateCoordinator(this::reloadRecipes, this::pulse); setPattern(); - if (Cfg.craftingQueue && pattern != null) { - this.queue = FusionAPI.getPlayerManager().getPlayer(player).getQueue(table.getName(), this.category); - } - Fusion.registerListener(this); - initialize(); + inventory = Bukkit.createInventory(null, layout.size(), inventoryName); } - private void mapSlots() { - this.resultSlots.clear(); - this.queuedSlots.clear(); - this.slots = new Slot[pattern.getPattern().length * 9]; - int k = -1; - int prevPage = -1, nextPage = -1; - int prevQueuePage = -1, nextQueuePage = -1; - for (String row : this.pattern.getPattern()) { - for (char c : row.toCharArray()) { - k++; - switch (c) { - case '=', 'o' -> { - this.slots[k] = Slot.BASE_RESULT_SLOT; - this.resultSlots.add(k); - } - case '>' -> { - this.slots[k] = Slot.BLOCKED_SLOT; - nextPage = k; - } - case '<' -> { - this.slots[k] = Slot.BLOCKED_SLOT; - prevPage = k; - } - case '-' -> { - this.slots[k] = Slot.QUEUED_SLOT; - this.queuedSlots.add(k); - } - case '}' -> { - this.slots[k] = Slot.BLOCKED_SLOT; - nextQueuePage = k; - } - case '{' -> { - this.slots[k] = Slot.BLOCKED_SLOT; - prevQueuePage = k; - } - default -> { - this.slots[k] = Slot.BLOCKED_SLOT; - this.blockedSlots.add(k); - } - } - } - } - this.nextPage = nextPage; - this.prevPage = prevPage; - this.nextQueuePage = nextQueuePage; - this.prevQueuePage = prevQueuePage; + public final void setPattern() { + pattern = category.getPattern() == null ? table.getRecipePattern() : category.getPattern(); + Map roles = new HashMap<>(table.getRecipePattern().getRoles()); + roles.putAll(pattern.getRoles()); + layout = PatternCompiler.compile(pattern.getPattern(), roles); + // Merge into a session copy; never mutate category or profession configuration. + decorations = new HashMap<>(table.getRecipePattern().getItems()); + decorations.putAll(pattern.getItems()); } + public final void resetPattern() { setPattern(); reloadRecipesTask(); } + public final void initialize() { setPattern(); inventory = Bukkit.createInventory(null, layout.size(), inventoryName); } - public void updateBlockedSlots(MessageData[] data) { - int totalItems = category.getRecipes().size(); - int queuedTotalItems = queue != null ? queue.getQueue().size() : 0; - int fullPages = totalItems / resultSlots.size(); - int rest = totalItems % resultSlots.size(); - int pages = (rest == 0) ? fullPages : (fullPages + 1); - - boolean includeBack = category.hasPrevious(); - int queuedPages = -1; - - if (!queuedSlots.isEmpty()) { - int queuedFullPages = queuedTotalItems / queuedSlots.size(); - int queuedRest = queuedTotalItems % queuedSlots.size(); - queuedPages = (queuedRest == 0) ? queuedFullPages : (queuedFullPages + 1); - } - - int k = -1; - HashMap items = pattern.getItems(); - - ArrayList leaveBlank = new ArrayList<>(); - ArrayList fill = new ArrayList<>(); - for (String row : pattern.getPattern()) { - for (char c : row.toCharArray()) { - k++; - ItemStack item = ItemUtils.replaceText(items.get(c), data); - if (!includeBack && c == '<' && page <= 0) { - leaveBlank.add(k); - continue; - } - if (c == '>' && page + 1 >= pages) { - leaveBlank.add(k); - continue; - } - if (c == '{' && (Cfg.craftingQueue && queuePage <= 0)) { - fill.add(k); - continue; - } - if (c == '}' && (Cfg.craftingQueue && ((queuedPages > -1) && (queuePage + 1 >= queuedPages)))) { - fill.add(k); - continue; - } - if (item != null && (c != '-')) { - inventory.setItem(k, item.clone()); - } else if (item != null) { - if (queue != null && queue.getQueuedItems().containsKey(k)) { - inventory.setItem(k, queue.getQueuedItems().get(k).getIcon()); - } else { - inventory.setItem(k, ProfessionsCfg.getQueueSlot(name)); - } - } - } - } - - for (Integer index : leaveBlank) { - if (inventory.getSize() > index + 1) - inventory.setItem(index, inventory.getItem(index + 1)); - else - inventory.setItem(index, inventory.getItem(index - 1)); - } - for (Integer index : fill) { - inventory.setItem(index, ProfessionsCfg.getFillItem(name)); - } - } - - public void initialize() { - this.inventory = Bukkit.createInventory(null, pattern.getInventorySize(), inventoryName); - mapSlots(); + public final void open(Player target) { + if (!player.getUniqueId().equals(target.getUniqueId())) throw new IllegalArgumentException("GUI belongs to another player"); + RecipeGui previous = ProfessionGuiRegistry.getLatestRecipeGui().get(player.getUniqueId()); + if (previous != null) previous.close(player, previous.getInventory()); + setPattern(); + if (inventory.getSize() != layout.size()) inventory = Bukkit.createInventory(null, layout.size(), inventoryName); + // Open first: Bukkit closes the previous inventory synchronously during this call. + target.openInventory(inventory); + ProfessionGuiRegistry.getLatestRecipeGui().put(player.getUniqueId(), this); + opened = true; + onOpen(); + reloadRecipes(); + updates.open(); } - public void reloadRecipes() { - if (!player.isOnline()) return; - + public final void reloadRecipesTask() { updates.request(); } + public final void reloadRecipes() { + if (!opened || !player.isOnline()) return; try { - // - // ─── 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 = VaultHook.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 (page >= pages && pages > 0) { - // Clamp page if out-of-range - this.page = pages - 1; - if (this.page != page) { - // Avoid infinite recursion - this.reloadRecipes(); - } - return; - } - - // - // ─── 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; - } - - // - // ─── 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); - } - - // - // ─── 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); - } - } + List available = new ArrayList<>(category.getName().equals("master") + ? table.getRecipes().values() : category.getRecipes()); + available.removeIf(recipe -> recipe.isHidden(player)); + List slots = layout.slots(SlotRole.RECIPE_LIST); + recipeCount = available.size(); + page = clampPage(page, recipeCount, slots.size()); + var counts = CraftingChecks.inventory(player); + var snapshot = RecipeViewSnapshot.capture(player, table); + renderSnapshot = snapshot; + recipes.clear(); + for (int offset = 0; offset < slots.size(); offset++) { + int index = page * slots.size() + offset; + int slot = slots.get(offset); + if (index < available.size()) { + Recipe recipe = available.get(index); + CalculatedRecipe calculated = CalculatedRecipe.create(recipe, new HashMap<>(counts), player, table, snapshot); + recipes.put(slot, recipe); + renderer.set(inventory, slot, calculated.getIcon()); + } else renderer.set(inventory, slot, table.getFillItem()); + } + renderDynamic(); + updateBlockedSlots(viewData()); + } catch (Exception exception) { + Fusion.getInstance().getLogger().warning("Cannot refresh crafting GUI for " + player.getName() + ": " + exception.getMessage()); + // Do not leave clickable bindings from a partially rendered page. 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; - } - - recipes.put(slotIndex, calc); - inventory.setItem(slotIndex, calc.getIcon().clone()); - } - - // - // ─── 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()); - } - } - - // - // ─── 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("category", category), - new MessageData("gui", getName()), - new MessageData("player", player.getName()), - new MessageData("queue_done", queue != null ? queue.getQueue().stream().filter(QueueItem::isDone).toList().size() : 0), - new MessageData("queue_size", queue != null ? queue.getQueue().size() : 0), - new MessageData("queue_time", queue != null ? queue.getVisualRemainingTotalTime() : 0), - new MessageData("bal", - CodexEngine.get().getVault() == null - ? 0 - : CodexEngine.get().getVault().getBalance(player)) - }); - - this.isLoaded = true; - } - 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 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()) { - requiresUpdate = true; - break; - } - } - if (requiresUpdate) { - Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::updateQueuedSlots, 20L); - } - this.isLoaded = true; - } - } - } - - public void reloadRecipesTask() { - Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::reloadRecipes, 1L); - } - - // Updates only the queued-slot icons/progress without rebuilding the whole GUI. - private void updateQueuedSlots() { - if (!player.isOnline()) return; - if (!Cfg.craftingQueue || queue == null || queuedSlots.isEmpty()) return; - - // Run the actual inventory updates on the main server thread - Bukkit.getScheduler().runTask(Fusion.getInstance(), () -> { - List allQueuedItems = new ArrayList<>(queue.getQueue()); - int queueSize = allQueuedItems.size(); - int queuePageSize = queuedSlots.size(); - - Integer[] queuedIndices = queuedSlots.toArray(new Integer[0]); - - // Reset all queued slots to the empty queue icon - for (int qIndex : queuedIndices) { - inventory.setItem(qIndex, ProfessionsCfg.getQueueSlot(table.getName())); - } - - // Clear the internal mapping and repopulate for current page only - this.queue.getQueuedItems().clear(); - - 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]); - - for (int q = qStart; q < qEnd && j < queuedIndices.length; q++, j++) { - QueueItem qi = allQueueItemsArray[q]; - int slot = queuedIndices[j]; - this.queue.getQueuedItems().put(slot, qi); - qi.updateIcon(); - inventory.setItem(slot, qi.getIcon().clone()); - } - } - - // Decide whether we need another update next second (any unfinished item) - boolean requiresUpdate = false; - for (QueueItem qi : allQueuedItems) { - if (!qi.isDone()) { - requiresUpdate = true; - break; - } - } - - if (requiresUpdate) { - Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::updateQueuedSlots, 20L); - } - }); - } - - private boolean validatePageCount() { - if (this.page <= 0) { - this.reloadRecipesTask(); - return false; - } - Collection allRecipes = table.getRecipes().values(); - int pageSize = resultSlots.size(); - int allRecipeCount = allRecipes.size(); - int page = this.page; - - int fullPages = allRecipeCount / pageSize; - int rest = allRecipeCount % pageSize; - int pages = (rest == 0) ? fullPages : (fullPages + 1); - if (page >= pages) { - this.page = pages; - this.reloadRecipesTask(); - return false; - } - return true; - } - - private void prevPage() { - if (this.page <= 0) { - cancel(true); - ProfessionsCfg.getGUI(name).open(player); - return; - } - this.page--; - if (this.validatePageCount()) { - this.reloadRecipesTask(); - } - } - - private void nextPage() { - this.page++; - if (this.validatePageCount()) { - this.reloadRecipesTask(); - } - } - - private boolean validateQueuePageCount() { - if (this.queuePage <= 0) { - this.reloadRecipesTask(); - return false; - } - Collection allQueuedItems = queue.getQueue(); - int pageSize = queuedSlots.size(); - int count = allQueuedItems.size(); - int page = this.queuePage; - - int fullPages = count / pageSize; - int rest = count % pageSize; - int pages = (rest == 0) ? fullPages : (fullPages + 1); - if (page >= pages) { - this.queuePage = pages; - this.reloadRecipesTask(); - return false; - } - return true; - } - - private void prevQueuePage() { - if (this.queuePage <= 0) - return; - this.queuePage--; - if (this.validateQueuePageCount()) { - this.reloadRecipesTask(); - } - } - - private void nextQueuePage() { - this.queuePage++; - if (this.validateQueuePageCount()) { - this.reloadRecipesTask(); - } - } - - public void setPattern() { - this.pattern = category.getPattern(); - if (!pattern.getItems().containsKey('<') || pattern.getItems().containsKey('>') || pattern.getItems() - .containsKey('{') || pattern.getItems().containsKey('}')) { - this.pattern.setItems(table.getRecipePattern().getItems()); - } - mapSlots(); - } - - public void resetPattern() { - this.pattern = category.getPattern(); - mapSlots(); - } - - public void setSlot(int i, Slot slot) { - this.slots[i] = slot; - } - - public Slot getSlot(int i) { - return this.slots[i]; - } - - public void open(Player player) { - ProfessionGuiRegistry.getLatestRecipeGui().put(player.getUniqueId(), this); - if (!isLoaded) - reloadRecipes(); - player.openInventory(inventory); - } - - /* Manage Crafting Algorithm */ - private boolean canCraft(CalculatedRecipe calculatedRecipe, int slot) { - Recipe recipe = calculatedRecipe.getRecipe(); - if (calculatedRecipe.getRecipe().getConditions().isMastery() && !PlayerLoader.getPlayer(player) - .hasMastered(table.getName())) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.error.noMastery", - player, - new MessageData("craftingTable", ProfessionsCfg.getTable(table.getName()))); - return false; - } - if (!calculatedRecipe.isCanCraft()) { - player.sendMessage(CraftingRequirementsCfg.getCanCraft(false)); - return false; - } - - if (!Objects.equals(this.recipes.get(slot), calculatedRecipe)) { - return false; - } - if (table.getLevelFunction().getLevel(player) < recipe.getConditions().getProfessionLevel()) { - CodexEngine.get() - .getMessageUtil() - .sendMessage("fusion.error.noLevel", player, new MessageData("recipe", recipe)); - return false; - } - if (ExperienceManager.getTotalExperience(this.player) < recipe.getConditions().getExpCost()) { - CodexEngine.get() - .getMessageUtil() - .sendMessage("fusion.error.noXP", player, new MessageData("recipe", recipe)); - return false; - } - if (recipe.getConditions().getMoneyCost() != 0 && CodexEngine.get().getVault() != null - && !CodexEngine.get().getVault().canPay(this.player, recipe.getConditions().getMoneyCost())) { - CodexEngine.get() - .getMessageUtil() - .sendMessage("fusion.error.noFunds", player, new MessageData("recipe", recipe)); - return false; - } - - // Instant-collect recipes never occupy a queue slot. - if (!(Cfg.instantCollect && recipe.getCraftingTime() <= 0)) { - // Check queue limits - int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); - int categoryLimit = - PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + "." + category.getName() + ".limit"); - int professionLimit = PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + ".limit"); - int limit = PlayerUtil.getPermOption(player, "fusion.queue.limit"); - - if (categoryLimit > 0 && limits[0] >= categoryLimit) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullCategory", - player, - new MessageData("limit", categoryLimit), - new MessageData("category", category.getName()), - new MessageData("profession", table.getName())); - return false; - } else if (professionLimit > 0 && limits[1] >= professionLimit) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.queue.fullProfession", - player, - new MessageData("limit", professionLimit), - new MessageData("profession", table.getName())); - return false; - } else if (limit > 0 && limits[2] >= limit) { - CodexEngine.get() - .getMessageUtil() - .sendMessage("fusion.queue.fullGlobal", player, new MessageData("limit", limit)); - return false; - } - } - - return true; - } - - private int getMaximumQueueAmount(CalculatedRecipe calculatedRecipe, int slot) { - if (!canCraft(calculatedRecipe, slot)) { - return 0; - } - - Recipe recipe = calculatedRecipe.getRecipe(); - Map available = new HashMap<>(); - for (ItemStack item : player.getInventory().getContents()) { - if (item == null || item.getType() == Material.AIR) continue; - available.merge(IngredientFingerprint.of(item), item.getAmount(), Integer::sum); - } - - Map required = new HashMap<>(); - for (ItemStack item : recipe.getItemsToTake()) { - if (item == null || item.getType() == Material.AIR) continue; - required.merge(IngredientFingerprint.of(item), item.getAmount(), Integer::sum); - } - - int maximum = Integer.MAX_VALUE; - for (Map.Entry entry : required.entrySet()) { - int needed = entry.getValue(); - if (needed <= 0) continue; - maximum = Math.min(maximum, available.getOrDefault(entry.getKey(), 0) / needed); - } - - int expCost = recipe.getConditions().getExpCost(); - if (expCost > 0) { - maximum = Math.min(maximum, ExperienceManager.getTotalExperience(player) / expCost); - } - - double moneyCost = recipe.getConditions().getMoneyCost(); - if (moneyCost > 0 && CodexEngine.get().getVault() != null) { - maximum = Math.min(maximum, (int) Math.floor( - CodexEngine.get().getVault().getBalance(player) / moneyCost)); - } - - PlayerRecipeLimit recipeLimit = PlayerLoader.getPlayer(player).getRecipeLimit(recipe); - if (recipe.getCraftingLimit() > 0) { - if (recipeLimit.getLimit() > 0 && recipeLimit.getCooldownTimestamp() > 0 && !recipeLimit.hasCooldown()) { - recipeLimit.resetLimit(); - } - int queuedForRecipe = (int) queue.getQueue().stream() - .filter(item -> item.getRecipe().equals(recipe)) - .count(); - maximum = Math.min(maximum, - Math.max(0, recipe.getCraftingLimit() - recipeLimit.getLimit() - queuedForRecipe)); - } - - if (!(Cfg.instantCollect && recipe.getCraftingTime() <= 0)) { - int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); - int categoryLimit = - PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + "." + category.getName() + ".limit"); - int professionLimit = PlayerUtil.getPermOption(player, "fusion.queue." + table.getName() + ".limit"); - int globalLimit = PlayerUtil.getPermOption(player, "fusion.queue.limit"); - if (categoryLimit > 0) maximum = Math.min(maximum, Math.max(0, categoryLimit - limits[0])); - if (professionLimit > 0) maximum = Math.min(maximum, Math.max(0, professionLimit - limits[1])); - if (globalLimit > 0) maximum = Math.min(maximum, Math.max(0, globalLimit - limits[2])); - } - - // A recipe with no consumable/cost/limit must still have a finite bulk operation. - return Math.max(0, Math.min(maximum, 1024)); - } - - private List takeIngredients(Recipe recipe) { - List removed = new ArrayList<>(); - PlayerInventory inventory = player.getInventory(); - for (ItemStack required : recipe.getItemsToTake()) { - if (required == null || required.getType() == Material.AIR) continue; - int need = required.getAmount(); - IngredientFingerprint fingerprint = IngredientFingerprint.of(required); - for (int slot = 0; slot < inventory.getSize() && need > 0; slot++) { - ItemStack present = inventory.getItem(slot); - if (present == null || present.getType() == Material.AIR || - !fingerprint.equals(IngredientFingerprint.of(present))) continue; - int taken = Math.min(need, present.getAmount()); - ItemStack refundItem = required.clone(); - refundItem.setAmount(taken); - removed.add(refundItem); - present.setAmount(present.getAmount() - taken); - inventory.setItem(slot, present.getAmount() <= 0 ? null : present); - need -= taken; - } - if (need > 0) { - giveItems(removed); - return null; - } } - return removed; } - private void giveItems(Collection items) { - if (items == null || items.isEmpty()) return; - Collection overflow = player.getInventory().addItem( - items.stream().map(ItemStack::clone).toArray(ItemStack[]::new)).values(); - for (ItemStack item : overflow) { - player.getWorld().dropItemNaturally(player.getLocation(), item); - } + private void pulse() { + if (!opened || player.getOpenInventory().getTopInventory() != inventory) { close(player, inventory); return; } + // Requirements can change without an inventory event (cooldown, money, permissions). + reloadRecipes(); } - private boolean queueOne(CalculatedRecipe calculatedRecipe, int slot, boolean reportErrors) { - if (!canCraft(calculatedRecipe, slot)) return false; - Recipe recipe = calculatedRecipe.getRecipe(); - List removed = takeIngredients(recipe); - if (removed == null) { - if (reportErrors) { - CodexEngine.get().getMessageUtil() - .sendMessage("fusion.error.insufficientItems", player, new MessageData("recipe", recipe)); - } - return false; - } - - int expCost = recipe.getConditions().getExpCost(); - double moneyCost = recipe.getConditions().getMoneyCost(); - if (expCost > 0) player.giveExp(-expCost); - if (moneyCost != 0 && CodexEngine.get().getVault() != null) { - CodexEngine.get().getVault().take(player, moneyCost); - } - - boolean added = queue.addRecipe(recipe, expCost); - if (!added) { - giveItems(removed); - if (expCost > 0) player.giveExp(expCost); - if (moneyCost != 0 && CodexEngine.get().getVault() != null) { - CodexEngine.get().getVault().give(player, moneyCost); - } - } - return added; + protected MessageData[] viewData() { + return new MessageData[]{new MessageData("level", renderSnapshot == null ? 0 : renderSnapshot.level()), + new MessageData("bal", renderSnapshot == null ? 0 : renderSnapshot.balance()), + new MessageData("category", category), new MessageData("gui", name), + new MessageData("player", player.getName())}; } - - private void queueMaximum(int slot) { - CalculatedRecipe calculatedRecipe = recipes.get(slot); - if (calculatedRecipe == null) return; - int maximum = getMaximumQueueAmount(calculatedRecipe, slot); - for (int i = 0; i < maximum; i++) { - if (!queueOne(calculatedRecipe, slot, i == 0)) break; + public final void updateBlockedSlots(MessageData[] data) { + for (int slot = 0; slot < layout.size(); slot++) { + SlotRole role = layout.role(slot); + if (role == SlotRole.RECIPE_LIST || role == SlotRole.QUEUE_LIST + || role == SlotRole.PREVIOUS_QUEUE_PAGE || role == SlotRole.NEXT_QUEUE_PAGE) continue; + boolean hidden = role == SlotRole.PREVIOUS_PAGE && page == 0 && !category.hasPrevious() + || role == SlotRole.NEXT_PAGE && page >= clampPage(Integer.MAX_VALUE, recipeCount, layout.slots(SlotRole.RECIPE_LIST).size()); + ItemStack icon = hidden ? table.getFillItem() : decorations.get(layout.symbol(slot)); + if (icon == null) icon = table.getFillItem(); + renderer.set(inventory, slot, ItemUtils.replaceText(icon == null ? null : icon.clone(), data)); } } - private boolean craft(int slot, boolean addToCursor) { - if (!recipes.containsKey(slot)) { - return false; - } - CalculatedRecipe calculatedRecipe = this.recipes.get(slot); - Recipe recipe = calculatedRecipe.getRecipe(); - if (craftingRecipe != null && craftingRecipe.equals(recipe)) { - cancel(true); - return false; - } - - cancel(true); - if (!canCraft(calculatedRecipe, slot)) return false; - - RecipeItem recipeResult = recipe.getSettings().getRecipeItem(); - ItemStack resultItem = recipeResult.getItemStack(); - - // 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)) { - if ((resultItem.getAmount() + cursor.getAmount()) > resultItem.getMaxStackSize()) { - return false; - } - } else if (cursor.getType() != Material.AIR) { - return false; - } - } - - // - // ─── 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(); - IngredientFingerprint neededFingerprint = IngredientFingerprint.of(required); - - for (int slotIndex = 0; slotIndex < inv.getSize() && need > 0; slotIndex++) { - ItemStack slotStack = inv.getItem(slotIndex); - if (slotStack == null || slotStack.getType() == Material.AIR) continue; - - IngredientFingerprint slotFingerprint = IngredientFingerprint.of(slotStack); - if (!neededFingerprint.equals(slotFingerprint)) continue; - - int available = slotStack.getAmount(); - int take = Math.min(available, need); - - slotStack.setAmount(available - take); - if (slotStack.getAmount() <= 0) { - inv.setItem(slotIndex, null); - } else { - inv.setItem(slotIndex, slotStack); - } - - ItemStack actuallyTaken = required.clone(); - actuallyTaken.setAmount(take); - removedSoFar.add(actuallyTaken); - - need -= take; - } - - 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; - } - } - - 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; - } - - // Manual crafting uses this list while its delayed task is running. Queue items - // persist their costs on the queue item instead, so they must not share it. - if (!Cfg.craftingQueue) { - 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) - ); - - showBossBar(this.player, recipe.getSettings().getRecipeItem().getItemStack(), cooldown); - - if (cooldown != 0) { - previousCursor = player.getOpenInventory().getCursor(); - player.getOpenInventory().setCursor(new ItemStack(Material.BARRIER)); - } - - craftingSuccess = false; - craftingRecipe = recipe; - craftingTask = Fusion.getInstance().runTaskLater(cooldown, () -> { - craftingSuccess = true; - - if (recipe.getResults().getCommands().isEmpty()) { - if (addToCursor) { - ItemStack cursor = this.player.getItemOnCursor(); - if (cursor.isSimilar(recipe.getSettings().getRecipeItem().getItemStack())) { - if (cursor.getAmount() < cursor.getMaxStackSize() - && cursor.getAmount() + recipe.getSettings().getRecipeItem().getAmount() - <= cursor.getMaxStackSize()) { - cursor.setAmount(cursor.getAmount() + recipe.getSettings().getRecipeItem().getAmount()); - this.player.setItemOnCursor(cursor); - } else { - craftingSuccess = false; - } - } else if (cursor.getType() == Material.AIR) { - this.player.setItemOnCursor(resultItem); - } else { - craftingSuccess = false; - } - } else { - boolean fits = calcWillFit(resultItem); - if (fits) { - HashMap notAdded = inv.addItem(resultItem); - if (!notAdded.isEmpty()) { - for (ItemStack stack : notAdded.values()) { - this.player.getWorld().dropItemNaturally(this.player.getLocation(), stack); - } - } - } else { - craftingSuccess = false; - } - } - } - - if (craftingSuccess) { - cancel(false); - CodexEngine.get().getVault().take(this.player, recipe.getConditions().getMoneyCost()); - // Commands - DelayedCommand.invoke(Fusion.getInstance(), player, recipe.getResults().getCommands()); - - // Experience - long professionExp = - recipe.getResults().getProfessionExp() + (long) (recipe.getResults().getProfessionExp() - * PlayerUtil.getProfessionExpBonusThroughPermissions(player, table.getName())); - if (professionExp > 0) { - FusionAPI.getEventServices() - .getProfessionService() - .giveProfessionExp(player, table, professionExp); - } - if (recipe.getResults().getVanillaExp() > 0) { - player.giveExp(recipe.getResults().getVanillaExp()); - } - - // Restart the crafting sequence if auto-crafting is enabled - if (PlayerLoader.getPlayer(player).isAutoCrafting() && !this.recipes.isEmpty()) { - reloadRecipesTask(); - boolean success = craft(slot, addToCursor); // Call this method again recursively - if (!success) - CodexEngine.get().getMessageUtil().sendMessage("fusion.autoCancelled", player); - } - } else { - cancel(true); - } - }); - } else { - int expCost = recipe.getConditions().getExpCost(); - double moneyCost = recipe.getConditions().getMoneyCost(); - if (expCost > 0) player.giveExp(-expCost); - if (moneyCost != 0 && CodexEngine.get().getVault() != null) { - CodexEngine.get().getVault().take(this.player, moneyCost); - } - boolean added = this.queue.addRecipe(recipe, expCost); - if (!added) { - giveItems(removedSoFar); - if (expCost > 0) player.giveExp(expCost); - if (moneyCost != 0 && CodexEngine.get().getVault() != null) { - CodexEngine.get().getVault().give(this.player, moneyCost); - } - return false; - } - } - return true; - } - - private boolean calcWillFit(ItemStack item) { - Inventory inv = Bukkit.createInventory(null, InventoryType.PLAYER); - inv.setContents(inventory.getContents()); - return inv.addItem(item).isEmpty(); - } - - public void executeCommands(Character c, HumanEntity player) { - Collection patternCommands = getPattern().getCommands(c); - if (patternCommands != null && !patternCommands.isEmpty()) { - DelayedCommand.invoke(Fusion.getInstance(), player, patternCommands, - Replacer.replacer("{crafting}", getName()), - Replacer.replacer("{inventoryName}", getInventoryName())); - } - } - - /* Manual Crafting Options */ - private void showBossBar(Player target, ItemStack item, double cooldown) { - if (cooldown == 0) + public final void click(InventoryClickEvent event) { + int slot = event.getRawSlot(); + if (slot < 0) return; + if (slot >= layout.size()) { + // Shift-click and collect-to-cursor can move items into/out of the top inventory. + if (event.isShiftClick() || event.getAction() == InventoryAction.COLLECT_TO_CURSOR) event.setCancelled(true); + reloadRecipesTask(); return; - - bar = Bukkit.createBossBar(CraftingRequirementsCfg.getBossBarTitle(item), - BarColor.BLUE, - BarStyle.SOLID, - BarFlag.PLAY_BOSS_MUSIC); - bar.setProgress(0d); - bar.addPlayer(target); - barTask = new BukkitRunnable() { - int count = 0; - - public void run() { - if (count >= cooldown * 20) { - this.cancel(); - bar.removeAll(); - } - - bar.setProgress(Math.min(1d, count / (cooldown * 20))); - - count++; - } - }.runTaskTimer(Fusion.getInstance(), 1L, 1L); - } - - private void cancel(boolean refundAll) { - if (!Cfg.craftingQueue) { - if (craftingTask == null) return; - craftingRecipe = null; - if (barTask != null) { - barTask.cancel(); - barTask = null; - bar.removeAll(); - bar = null; - } - - if (!craftingSuccess && PlayerLoader.getPlayer(player).isAutoCrafting()) { - CodexEngine.get().getMessageUtil().sendMessage("fusion.autoCancelled", player); - } - - if (player.getOpenInventory().getCursor() != null - && player.getOpenInventory().getCursor().getType() == Material.BARRIER) { - if (previousCursor != null) { - player.getOpenInventory().setCursor(previousCursor); - previousCursor = null; - } else { - player.getOpenInventory().setCursor(new ItemStack(Material.AIR)); - } - } - - if (craftingTask != null) - craftingTask.cancel(); - craftingTask = null; - - 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().dropItem(player.getLocation(), item); - } - } - this.refund.clear(); } - } - - /* Event-Handling */ - public void click(InventoryClickEvent event) { event.setCancelled(true); - if ((event.getRawSlot() >= slots.length)) { - if (event.getCursor().getType() == Material.BARRIER) - event.setCancelled(true); - return; - } - boolean shiftCraftAll = Cfg.craftingQueue - && event.isShiftClick() - && event.isLeftClick() - && slots[event.getRawSlot()].equals(Slot.BASE_RESULT_SLOT); - if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY && !shiftCraftAll) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - return; - } - - Character c = pattern.getSlot(event.getRawSlot()); - executeCommands(c, event.getWhoClicked()); - - // Close on click - if (pattern.getCloseOnClickSlots().contains(c)) { - Bukkit.getScheduler().runTask(Fusion.getInstance(), () -> event.getWhoClicked().closeInventory()); - } - - if (slots[event.getRawSlot()].equals(Slot.BLOCKED_SLOT)) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - if ((nextPage != -1) && (event.getSlot() == nextPage)) { - this.nextPage(); - return; - } - if (prevPage != -1 && event.getSlot() == prevPage) { - this.prevPage(); - return; - } - if ((nextQueuePage != -1) && event.getSlot() == nextQueuePage) { - // Open the next queue page - this.nextQueuePage(); - return; - } - if (prevQueuePage != -1 && event.getSlot() == prevQueuePage) { - // Open the previous queue page - this.prevQueuePage(); - } - return; - } - if (slots[event.getRawSlot()].equals(Slot.BASE_RESULT_SLOT)) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - Fusion.getInstance().runSync(() -> { - this.reloadRecipes(); - if (Cfg.craftingQueue && event.isShiftClick() && event.isLeftClick()) { - this.queueMaximum(event.getRawSlot()); - } else { - this.craft(event.getRawSlot(), false); - } - this.reloadRecipesTask(); - }); - return; - } - if (slots[event.getRawSlot()].equals(Slot.QUEUED_SLOT)) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - if (queuedSlots.contains(event.getSlot())) { - // Interact with a queued item - QueueItem item = queue.getQueuedItems().get(event.getSlot()); - if (item == null) return; - if (item.isDone()) { - if (event.isLeftClick()) { - queue.finishRecipe(item); - this.reloadRecipes(); - } else if (event.isRightClick()) { - int queueSize = - queue.getQueue().size(); // Estimated time that is required to finish all recipes - queue.finishAllRecipes(); - Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::reloadRecipes, queueSize + 1); - } - } else { - queue.removeRecipe(item, true); - } - } - return; - } - if (event.getCursor().getType() != Material.AIR) { - if (Slot.SPECIAL_CRAFTING_SLOT.canHoldItem(event.getCursor()) == null) { - event.setResult(Event.Result.DENY); - return; - } - } - this.reloadRecipesTask(); - } - - public void close(Player p, Inventory inv) { - if (inv == null) { - return; - } - Inventory pInventory = p.getInventory(); - if (inv.equals(this.inventory) && !Cfg.craftingQueue) { - 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)) { - continue; - } - ItemStack it = inv.getItem(i); - if (it != null) { - pInventory.addItem(it) - .values() - .stream() - .filter(Objects::nonNull) - .forEach(itemStack -> p.getWorld().dropItem(p.getLocation(), itemStack)); - } - } - cancel(true); - inv.clear(); - } - ProfessionGuiRegistry.getLatestRecipeGui().remove(p.getUniqueId()); - } - - /* - Event to prevent the player from dragging items into the crafting slots - while doing manual crafting - */ - public void onDrag(InventoryDragEvent e) { - if (!(e.getWhoClicked() instanceof Player)) { + executeCommands(layout.symbol(slot), player); + if (pattern.getCloseOnClickSlots().contains(layout.symbol(slot))) { + Bukkit.getScheduler().runTask(Fusion.getInstance(), player::closeInventory); return; } - if (e.getInventory().equals(this.inventory) && !Cfg.craftingQueue) { - if (e.getOldCursor().getType() == Material.BARRIER) - e.setCancelled(true); - if (e.getRawSlots() - .stream() - .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)) { - e.setResult(Event.Result.DENY); - } - reloadRecipesTask(); - } - } - - /* - Event to prevent the player from dropping items into the crafting slots - while doing manual crafting - */ - public void drop(PlayerDropItemEvent event) { - Player player = event.getPlayer(); - if (this.getInventory().getViewers().contains(player) && !Cfg.craftingQueue) { - ItemStack stack = event.getItemDrop().getItemStack(); - if (stack.getType() == Material.BARRIER) { - event.getItemDrop().remove(); - if (player.getOpenInventory().getCursor() == null - || player.getOpenInventory().getCursor().getType() == Material.AIR) - player.getOpenInventory().setCursor(stack); - - cancel(true); - } - } - } - - public static void resetRecipeHashes() { - recipeCache.clear(); - } + switch (layout.role(slot)) { + case PREVIOUS_PAGE -> { + if (page == 0) ProfessionsCfg.getGUI(name).open(player); + else { page--; reloadRecipesTask(); } + } + case NEXT_PAGE -> { page++; reloadRecipesTask(); } + case RECIPE_LIST -> { + Recipe recipe = recipes.get(slot); + if (recipe != null) onCraft(recipe, event.isShiftClick() && event.isLeftClick()); + reloadRecipesTask(); + } + default -> { onModeClick(slot, event); reloadRecipesTask(); } + } + } + public void onDrag(InventoryDragEvent event) { + if (event.getRawSlots().stream().anyMatch(slot -> slot < inventory.getSize())) event.setCancelled(true); + reloadRecipesTask(); + } + public void drop(PlayerDropItemEvent event) { reloadRecipesTask(); } + public final void close(Player target, Inventory closed) { + if (closed != inventory) return; + opened = false; + updates.close(); + onClose(); + ProfessionGuiRegistry.getLatestRecipeGui().remove(player.getUniqueId(), this); + } + public final void executeCommands(Character symbol, HumanEntity target) { + DelayedCommand.invoke(Fusion.getInstance(), target, pattern.getCommands(symbol), + Replacer.replacer("{crafting}", name), Replacer.replacer("{inventoryName}", inventoryName)); + } + public static int clampPage(int page, int count, int size) { + return size <= 0 ? 0 : Math.max(0, Math.min(page, Math.max(0, (count - 1) / size))); + } + public static void resetRecipeHashes() { /* No gameplay eligibility cache. */ } + protected void onOpen() { } + protected void onClose() { } + protected abstract void onCraft(Recipe recipe, boolean bulk); + protected abstract void renderDynamic(); + protected void onModeClick(int slot, InventoryClickEvent event) { } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/RecipeGuiFactory.java b/src/main/java/studio/magemonkey/fusion/gui/RecipeGuiFactory.java new file mode 100644 index 0000000..3dfd397 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/RecipeGuiFactory.java @@ -0,0 +1,13 @@ +package studio.magemonkey.fusion.gui; + +import org.bukkit.entity.Player; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.data.recipes.CraftingTable; + +public final class RecipeGuiFactory { + private RecipeGuiFactory() { } + public static RecipeGui create(Player player, CraftingTable table, Category category) { + return Cfg.craftingQueue ? new QueuedRecipeGUI(player, table, category) : new ManualRecipeGUI(player, table, category); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/CompiledPattern.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/CompiledPattern.java new file mode 100644 index 0000000..582e7d7 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/CompiledPattern.java @@ -0,0 +1,21 @@ +package studio.magemonkey.fusion.gui.recipe; + +import java.util.*; + +/** Immutable layout shared by both recipe GUIs. Contains no player state. */ +public final class CompiledPattern { + private final char[] symbols; + private final List roles; + public CompiledPattern(char[] symbols, List roles) { + this.symbols = symbols.clone(); + this.roles = List.copyOf(roles); + } + public int size() { return symbols.length; } + public char symbol(int slot) { return symbols[slot]; } + public SlotRole role(int slot) { return roles.get(slot); } + public List slots(SlotRole role) { + List result = new ArrayList<>(); + for (int i = 0; i < size(); i++) if (roles.get(i) == role) result.add(i); + return List.copyOf(result); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/GuiUpdateCoordinator.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/GuiUpdateCoordinator.java new file mode 100644 index 0000000..2ef9a27 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/GuiUpdateCoordinator.java @@ -0,0 +1,34 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; +import studio.magemonkey.fusion.Fusion; + +/** One owned task per open session; multiple requests in one tick coalesce. */ +public final class GuiUpdateCoordinator implements AutoCloseable { + private final Runnable fullRefresh; + private final Runnable pulse; + private BukkitTask task; + private boolean dirty; + private int ticks; + public GuiUpdateCoordinator(Runnable fullRefresh, Runnable pulse) { + this.fullRefresh = fullRefresh; this.pulse = pulse; + } + public void open() { + close(); + dirty = true; + ticks = 0; + task = Bukkit.getScheduler().runTaskTimer(Fusion.getInstance(), this::tick, 1L, 1L); + } + public void request() { dirty = true; } + public void tick() { + boolean refreshed = dirty; + if (refreshed) { dirty = false; fullRefresh.run(); } + if (++ticks % 20 == 0 && !refreshed) pulse.run(); + } + @Override public void close() { + if (task != null) task.cancel(); + task = null; + dirty = false; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/PatternCompiler.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/PatternCompiler.java new file mode 100644 index 0000000..a987c84 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/PatternCompiler.java @@ -0,0 +1,40 @@ +package studio.magemonkey.fusion.gui.recipe; + +import java.util.*; +import studio.magemonkey.fusion.data.professions.pattern.InventoryPattern; + +public final class PatternCompiler { + private static final Map DEFAULTS = Map.of( + 'o', SlotRole.RECIPE_LIST, '=', SlotRole.RECIPE_LIST, '-', SlotRole.QUEUE_LIST, + '<', SlotRole.PREVIOUS_PAGE, '>', SlotRole.NEXT_PAGE, + '{', SlotRole.PREVIOUS_QUEUE_PAGE, '}', SlotRole.NEXT_QUEUE_PAGE); + private PatternCompiler() { } + + public static CompiledPattern compile(InventoryPattern pattern) { + return compile(pattern.getPattern(), pattern.getRoles()); + } + + public static CompiledPattern compile(String[] rows, Map overrides) { + if (rows == null || rows.length < 1 || rows.length > 6) + throw new IllegalArgumentException("Recipe pattern must contain 1 to 6 rows"); + Map bindings = new HashMap<>(DEFAULTS); + bindings.putAll(overrides); + char[] symbols = new char[rows.length * 9]; + List roles = new ArrayList<>(); + for (int row = 0; row < rows.length; row++) { + if (rows[row] == null || rows[row].length() != 9) + throw new IllegalArgumentException("Pattern row " + (row + 1) + " must contain exactly 9 characters"); + for (int column = 0; column < 9; column++) { + char symbol = rows[row].charAt(column); + symbols[row * 9 + column] = symbol; + roles.add(bindings.getOrDefault(symbol, SlotRole.DECORATION)); + } + } + if (!roles.contains(SlotRole.RECIPE_LIST)) + throw new IllegalArgumentException("Recipe pattern needs at least one recipe-list slot"); + if (!roles.contains(SlotRole.QUEUE_LIST) && (roles.contains(SlotRole.PREVIOUS_QUEUE_PAGE) + || roles.contains(SlotRole.NEXT_QUEUE_PAGE))) + throw new IllegalArgumentException("Queue navigation requires queue-list slots"); + return new CompiledPattern(symbols, roles); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java index 11baae1..16aa869 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java @@ -12,23 +12,14 @@ 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. - */ +/** Routes both crafting modes through the currently open inventory session. */ 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; @@ -42,7 +33,7 @@ private RecipeGui findGuiFor(Player player, Inventory inv) { @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onInventoryClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player p)) return; - Inventory inv = event.getClickedInventory(); + Inventory inv = event.getView().getTopInventory(); RecipeGui gui = findGuiFor(p, inv); if (gui == null || inv == null) return; @@ -102,9 +93,6 @@ public void onItemPickup(EntityPickupItemEvent event) { @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; diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeViewSnapshot.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeViewSnapshot.java new file mode 100644 index 0000000..39d66d0 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeViewSnapshot.java @@ -0,0 +1,28 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.entity.Player; +import studio.magemonkey.codex.CodexEngine; +import studio.magemonkey.fusion.cfg.sql.SQLManager; +import studio.magemonkey.fusion.data.player.*; +import studio.magemonkey.fusion.data.recipes.CraftingTable; +import studio.magemonkey.fusion.util.ExperienceManager; +import java.util.Map; + +/** One short-lived read snapshot per refresh, never used to authorize a later click. */ +public record RecipeViewSnapshot(int level, boolean mastered, int experience, double balance, + boolean economyAvailable, Map limits) { + public static RecipeViewSnapshot capture(Player player, CraftingTable table) { + var profession = PlayerLoader.getPlayer(player).getProfession(table); + var vault = CodexEngine.get().getVault(); + return new RecipeViewSnapshot(profession == null ? 0 : profession.getLevel(), + profession != null && profession.isMastered(), ExperienceManager.getTotalExperience(player), + vault == null ? 0 : vault.getBalance(player), vault != null, + SQLManager.recipeLimits().getRecipeLimits(player.getUniqueId())); + } + public PlayerRecipeLimit limit(String path) { + PlayerRecipeLimit limit = limits.get(path); + if (limit == null || (limit.getCooldownTimestamp() > 0 && !limit.hasCooldown())) + return new PlayerRecipeLimit(path, 0, -1); + return limit; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRenderer.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRenderer.java new file mode 100644 index 0000000..2d6ce5e --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRenderer.java @@ -0,0 +1,13 @@ +package studio.magemonkey.fusion.gui.recipe; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import java.util.Objects; + +/** Never erase a slot just to repaint it; send only actual item changes. */ +public final class SlotRenderer { + public void set(Inventory inventory, int slot, ItemStack desired) { + if (!Objects.equals(inventory.getItem(slot), desired)) + inventory.setItem(slot, desired == null ? null : desired.clone()); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRole.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRole.java new file mode 100644 index 0000000..c9d3df0 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/SlotRole.java @@ -0,0 +1,5 @@ +package studio.magemonkey.fusion.gui.recipe; + +public enum SlotRole { + RECIPE_LIST, QUEUE_LIST, PREVIOUS_PAGE, NEXT_PAGE, PREVIOUS_QUEUE_PAGE, NEXT_QUEUE_PAGE, DECORATION +} diff --git a/src/test/java/studio/magemonkey/fusion/cfg/sql/PersistenceTest.java b/src/test/java/studio/magemonkey/fusion/cfg/sql/PersistenceTest.java new file mode 100644 index 0000000..f2eab71 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/cfg/sql/PersistenceTest.java @@ -0,0 +1,248 @@ +package studio.magemonkey.fusion.cfg.sql; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.cfg.ProfessionsCfg; +import studio.magemonkey.fusion.cfg.sql.tables.*; +import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; +import studio.magemonkey.fusion.data.professions.Profession; +import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.data.queue.QueueItem; +import studio.magemonkey.fusion.data.queue.CraftingQueue; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import studio.magemonkey.fusion.data.recipes.Recipe; + +import java.nio.file.Path; +import java.sql.*; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PersistenceTest { + @TempDir Path directory; + Connection connection; + MockedStatic sql; + MockedStatic fusion; + FusionQueuesSQL queues; + FusionRecipeLimitsSQL limits; + UUID uuid = UUID.randomUUID(); + Recipe recipe; + Category category; + + @BeforeEach void open() throws Exception { + String url = "jdbc:sqlite:" + directory.resolve("fusion.db"); + connection = DriverManager.getConnection(url); + sql = mockStatic(SQLManager.class); + sql.when(SQLManager::connection).thenReturn(connection); + sql.when(SQLManager::openConnection).thenAnswer(invocation -> DriverManager.getConnection(url)); + sql.when(SQLManager::getIdColumn).thenReturn("Id INTEGER PRIMARY KEY AUTOINCREMENT,"); + Fusion plugin = mock(Fusion.class); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + fusion = mockStatic(Fusion.class); + fusion.when(Fusion::getInstance).thenReturn(plugin); + queues = new FusionQueuesSQL(); + limits = new FusionRecipeLimitsSQL(); + sql.when(SQLManager::queues).thenReturn(queues); + sql.when(SQLManager::recipeLimits).thenReturn(limits); + recipe = mock(Recipe.class); + when(recipe.getRecipePath()).thenReturn("smithing.iron_tools.pickaxe"); + when(recipe.getCraftingTime()).thenReturn(60); + category = mock(Category.class); + when(category.getName()).thenReturn("iron_tools"); + when(category.getRecipe("pickaxe")).thenReturn(recipe); + } + + @AfterEach void close() throws Exception { + if (sql != null) sql.close(); + if (fusion != null) fusion.close(); + if (connection != null) connection.close(); + } + + QueueItem item(int progress) { + return new QueueItem(-1, "smithing", category, recipe, 1000L, progress); + } + + @Test void repeatedSaveRetainsIdentityDurationTimestampAndPaidCost() throws Exception { + QueueItem item = item(20); + item.setPaidExpCost(17); + assertTrue(queues.setQueueItem(uuid, item)); + long id = item.getId(); + assertTrue(id > 0); + item.progressOffline(10); + item.setTimestamp(9000L); + assertTrue(queues.setQueueItem(uuid, item)); + when(recipe.getCraftingTime()).thenReturn(999); // config changed since enqueue + var loaded = queues.getQueueItems(uuid, "smithing", category); + assertEquals(1, loaded.size()); + QueueItem restored = loaded.get(0); + assertEquals(id, restored.getId()); + assertEquals(60, restored.getCraftingTime()); + assertEquals(30, restored.getSavedSeconds()); + assertEquals(9000L, restored.getTimestamp()); + assertEquals(17, restored.getPaidExpCost()); + assertFalse(restored.isDone()); + } + + @Test void completedItemStaysCompletedAfterReload() { + QueueItem item = item(60); + assertTrue(queues.setQueueItem(uuid, item)); + assertTrue(queues.getQueueItems(uuid, "smithing", category).get(0).isDone()); + } + + @Test void receiptSurvivesReloadAndIsNotRebuiltFromEditedRecipe() { + QueueItem queued = item(0); + queued.setReceipt(new CraftingReceipt(java.util.List.of(), 13, 12.5)); + assertTrue(queues.setQueueItem(uuid, queued)); + CraftingReceipt loaded = queues.getQueueItems(uuid, "smithing", category).get(0).getReceipt(); + assertNotNull(loaded); + assertEquals(13, loaded.getExperience()); + assertEquals(12.5, loaded.getMoney()); + assertTrue(loaded.getItems().isEmpty()); + } + + @Test void categorylessViewLoadsCanonicalRecipePaths() { + assertTrue(queues.setQueueItem(uuid, item(0))); + Category master = mock(Category.class); + when(master.getName()).thenReturn("master"); + when(master.getRecipes()).thenReturn(new java.util.ArrayList<>(java.util.List.of(recipe))); + assertEquals(1, queues.getQueueItems(uuid, "smithing", master).size()); + } + + @Test void queueMatchingDoesNotTreatUnderscoresAsWildcardsAndKeepsOrder() throws Exception { + QueueItem first = item(0); + QueueItem second = item(0); + assertTrue(queues.setQueueItem(uuid, first)); + assertTrue(queues.setQueueItem(uuid, second)); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("INSERT INTO fusion_queues(UUID,RecipePath) VALUES ('" + uuid + + "','smithing.ironXtools.pickaxe')"); + } + var loaded = queues.getQueueItems(uuid, "smithing", category); + assertEquals(2, loaded.size()); + assertEquals(first.getId(), loaded.get(0).getId()); + assertEquals(second.getId(), loaded.get(1).getId()); + } + + @Test void limitsAreFreshAndBoundMutatorsPersistWithoutLogout() throws Exception { + String path = recipe.getRecipePath(); + PlayerRecipeLimit old = limits.getRecipeLimit(uuid, path); + limits.incrementLimit(uuid, path, 2, 0); + old.incrementLimit(1); // must add to persisted 2 rather than overwrite with stale 1 + assertEquals(3, limits.getRecipeLimit(uuid, path).getLimit()); + limits.getRecipeLimit(uuid, path).resetLimit(); + assertEquals(0, limits.getRecipeLimit(uuid, path).getLimit()); + limits.incrementLimit(uuid, path, 1, 60); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("UPDATE fusion_limits SET Timestamp=1"); + } + assertEquals(0, limits.getRecipeLimit(uuid, path).getLimit()); + limits.incrementLimit(uuid, path, 1, 0); + assertEquals(1, new FusionRecipeLimitsSQL().getRecipeLimit(uuid, path).getLimit()); + } + + @Test void claimAndLimitAreAtomicAndCannotBeRepeated() { + when(recipe.getCraftingLimit()).thenReturn(1); + QueueItem first = item(60); + QueueItem second = item(60); + assertTrue(queues.setQueueItem(uuid, first)); + assertTrue(queues.setQueueItem(uuid, second)); + assertTrue(queues.claimQueueItem(uuid, first)); + assertFalse(queues.claimQueueItem(uuid, first)); + assertFalse(queues.claimQueueItem(uuid, second)); + assertEquals(1, limits.getRecipeLimit(uuid, recipe.getRecipePath()).getLimit()); + assertEquals(second.getId(), queues.getQueueItems(uuid, "smithing", category).get(0).getId()); + } + + @Test void failedLimitWriteRollsBackQueueRemoval() throws Exception { + when(recipe.getCraftingLimit()).thenReturn(1); + QueueItem item = item(60); + assertTrue(queues.setQueueItem(uuid, item)); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DROP TABLE fusion_limits"); + } + assertFalse(queues.claimQueueItem(uuid, item)); + assertEquals(1, queues.getQueueItems(uuid, "smithing", category).size()); + } + + @Test void unfinishedOrWrongOwnerCannotClaim() { + QueueItem item = item(30); + assertTrue(queues.setQueueItem(uuid, item)); + assertFalse(queues.claimQueueItem(uuid, item)); + assertFalse(queues.claimQueueItem(UUID.randomUUID(), item)); + assertEquals(1, queues.getQueueItems(uuid, "smithing", category).size()); + } + + @Test void newProfessionWritesUseLogicalIdentityAndAtomicExperience() { + FusionProfessionsSQL professions = new FusionProfessionsSQL(); + sql.when(SQLManager::professions).thenReturn(professions); + Profession profession = new Profession(-1, uuid, "smithing", 0, false, true); + professions.setProfession(uuid, profession); + profession.addExp(10); + profession.setMastered(true); + profession.addExp(20); + Profession loaded = professions.getProfessions(uuid).get(0); + assertEquals(30, loaded.getExp()); + assertTrue(loaded.isMastered()); + } + + @Test void offlineTimeIsDistributedOnceAndSavingDoesNotStopLiveQueue() { + boolean previous = Cfg.updateQueueOffline; + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic config = mockStatic(ProfessionsCfg.class)) { + Cfg.updateQueueOffline = true; + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskTimer(any(org.bukkit.plugin.Plugin.class), any(Runnable.class), anyLong(), anyLong())) + .thenReturn(task); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + QueueItem first = item(50); + QueueItem second = item(0); + long timestamp = System.currentTimeMillis() - 25000L; + first.setTimestamp(timestamp); + second.setTimestamp(timestamp); + assertTrue(queues.setQueueItem(uuid, first)); + assertTrue(queues.setQueueItem(uuid, second)); + CraftingQueue queue = new CraftingQueue(player, "smithing", category); + assertTrue(queue.getQueue().get(0).isDone()); + int progress = queue.getQueue().get(1).getSavedSeconds(); + assertTrue(progress >= 15 && progress < 20); + queues.saveCraftingQueue(queue); + verify(task, never()).cancel(); + CraftingQueue rejoined = new CraftingQueue(player, "smithing", category); + assertTrue(rejoined.getQueue().get(1).getSavedSeconds() <= progress + 1); + } finally { + Cfg.updateQueueOffline = previous; + } + } + + @Test void offlineProgressCanBeDisabled() { + boolean previous = Cfg.updateQueueOffline; + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic config = mockStatic(ProfessionsCfg.class)) { + Cfg.updateQueueOffline = false; + BukkitScheduler scheduler = mock(BukkitScheduler.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskTimer(any(org.bukkit.plugin.Plugin.class), any(Runnable.class), anyLong(), anyLong())) + .thenReturn(mock(BukkitTask.class)); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + assertTrue(queues.setQueueItem(uuid, item(20))); + CraftingQueue queue = new CraftingQueue(player, "smithing", category); + assertEquals(20, queue.getQueue().get(0).getSavedSeconds()); + assertFalse(queue.getQueue().get(0).isDone()); + } finally { + Cfg.updateQueueOffline = previous; + } + } +} diff --git a/src/test/java/studio/magemonkey/fusion/crafting/CraftingReceiptTest.java b/src/test/java/studio/magemonkey/fusion/crafting/CraftingReceiptTest.java new file mode 100644 index 0000000..cf8bc9d --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/crafting/CraftingReceiptTest.java @@ -0,0 +1,64 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.UnsafeValues; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CraftingReceiptTest { + @Test void bukkitItemReceiptsRoundTripWithoutReconstructingRequirements() { + org.mockbukkit.mockbukkit.MockBukkit.mock(); + try { + ItemStack original = new ItemStack(Material.DIAMOND, 3); + var meta = original.getItemMeta(); + meta.setDisplayName("Recorded ingredient"); + var key = new org.bukkit.NamespacedKey("fusion", "receipt-test"); + meta.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "original"); + original.setItemMeta(meta); + CraftingReceipt receipt = new CraftingReceipt(List.of(original), 5, 1.25); + original.setAmount(20); + CraftingReceipt loaded = CraftingReceipt.decode(receipt.encode()); + assertEquals(Material.DIAMOND, loaded.getItems().get(0).getType()); + assertEquals(3, loaded.getItems().get(0).getAmount()); + assertEquals("Recorded ingredient", loaded.getItems().get(0).getItemMeta().getDisplayName()); + assertEquals("original", loaded.getItems().get(0).getItemMeta().getPersistentDataContainer() + .get(key, org.bukkit.persistence.PersistentDataType.STRING)); + ItemStack returned = loaded.getItems().get(0); + returned.setAmount(1); + assertEquals(3, loaded.getItems().get(0).getAmount()); + assertEquals(5, loaded.getExperience()); + assertEquals(1.25, loaded.getMoney()); + } finally { org.mockbukkit.mockbukkit.MockBukkit.unmock(); } + } + @Test void malformedReceiptCannotSilentlyBecomeAFreeRefund() { + assertNull(CraftingReceipt.decode(null)); + assertThrows(IllegalStateException.class, () -> CraftingReceipt.decode("invalid")); + } + + @Test void removalRecordsActualStackMetadataAndRefundRestoresIt() { + var server = org.mockbukkit.mockbukkit.MockBukkit.mock(); + try { + var player = server.addPlayer(); + ItemStack actual = new ItemStack(Material.DIAMOND, 3); + var meta = actual.getItemMeta(); + var key = new org.bukkit.NamespacedKey("fusion", "source"); + meta.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "player-stack"); + actual.setItemMeta(meta); + player.getInventory().setItem(0, actual); + // The recipe only requires a material/amount; the receipt must retain the actual PDC. + CraftingCostService service = new CraftingCostService(); + var removed = service.takeItems(player, List.of(new ItemStack(Material.DIAMOND, 2))); + assertNotNull(removed); + assertEquals(1, player.getInventory().getItem(0).getAmount()); + assertEquals("player-stack", removed.get(0).getItemMeta().getPersistentDataContainer() + .get(key, org.bukkit.persistence.PersistentDataType.STRING)); + service.giveItems(player, removed); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + } finally { org.mockbukkit.mockbukkit.MockBukkit.unmock(); } + } +} diff --git a/src/test/java/studio/magemonkey/fusion/crafting/ManualCraftingServiceTest.java b/src/test/java/studio/magemonkey/fusion/crafting/ManualCraftingServiceTest.java new file mode 100644 index 0000000..e1ffca8 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/crafting/ManualCraftingServiceTest.java @@ -0,0 +1,74 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.*; +import org.junit.jupiter.api.Test; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.data.player.*; +import studio.magemonkey.fusion.data.queue.CraftingReceipt; +import studio.magemonkey.fusion.data.recipes.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class ManualCraftingServiceTest { + @Test void cancelledSessionRefundsOnceAndOldCallbackCannotCompleteNewCraft() { + CraftingReceipt receipt = new CraftingReceipt(List.of(), 4, 0); + Player player = mock(Player.class); + CraftingTable table = mock(CraftingTable.class); + Recipe first = mock(Recipe.class), second = mock(Recipe.class); + when(first.getRecipePath()).thenReturn("first"); when(second.getRecipePath()).thenReturn("second"); + when(first.getCraftingTime()).thenReturn(10); when(second.getCraftingTime()).thenReturn(10); + try (var bukkit = mockStatic(Bukkit.class); var fusion = mockStatic(Fusion.class); + var checks = mockStatic(CraftingChecks.class); + var costs = mockConstruction(CraftingCostService.class, (mock, context) -> + when(mock.charge(any(), any())).thenReturn(receipt))) { + Fusion plugin = mock(Fusion.class); fusion.when(Fusion::getInstance).thenReturn(plugin); + checks.when(() -> CraftingChecks.canCraft(any(), any(), any(), isNull())).thenReturn(true); + BukkitScheduler scheduler = mock(BukkitScheduler.class); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + List callbacks = new ArrayList<>(); + BukkitTask task = mock(BukkitTask.class); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(call -> { + callbacks.add(call.getArgument(1)); return task; + }); + ManualCraftingService service = new ManualCraftingService(player, table); + assertEquals(CraftingResult.SUCCESS, service.start(first, () -> {})); + service.cancel(); service.cancel(); + verify(costs.constructed().get(0), times(1)).refund(player, receipt); + assertEquals(CraftingResult.SUCCESS, service.start(second, () -> {})); + callbacks.get(0).run(); + assertTrue(service.isRunning()); + assertSame(second, service.getRecipe()); + service.cancel(); + assertFalse(service.isRunning()); + verify(costs.constructed().get(0), times(2)).refund(player, receipt); + } + } + @Test void successfulCompletionDeliversOnceWithoutRefund() { + Player player = mock(Player.class); when(player.isOnline()).thenReturn(true); + CraftingTable table = mock(CraftingTable.class); + Recipe recipe = mock(Recipe.class, RETURNS_DEEP_STUBS); + when(recipe.getRecipePath()).thenReturn("smith.tools.pick"); + CraftingReceipt receipt = new CraftingReceipt(List.of(), 0, 0); + try (var bukkit = mockStatic(Bukkit.class); var fusion = mockStatic(Fusion.class); + var checks = mockStatic(CraftingChecks.class); var loader = mockStatic(PlayerLoader.class); + var costs = mockConstruction(CraftingCostService.class, (mock, context) -> when(mock.charge(any(), any())).thenReturn(receipt)); + var rewards = mockConstruction(CraftingRewardService.class)) { + Fusion plugin = mock(Fusion.class); fusion.when(Fusion::getInstance).thenReturn(plugin); + loader.when(() -> PlayerLoader.getPlayer(player)).thenReturn(mock(FusionPlayer.class)); + checks.when(() -> CraftingChecks.canCraft(any(), any(), any(), isNull())).thenReturn(true); + BukkitScheduler scheduler = mock(BukkitScheduler.class); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + List callbacks = new ArrayList<>(); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(call -> { + callbacks.add(call.getArgument(1)); return mock(BukkitTask.class); + }); + ManualCraftingService service = new ManualCraftingService(player, table); + service.start(recipe, () -> {}); + callbacks.get(0).run(); callbacks.get(0).run(); service.cancel(); + assertEquals(1, rewards.constructed().size()); + verify(rewards.constructed().get(0)).give(player, table, recipe, recipe.getResults().getItems()); + verify(costs.constructed().get(0), never()).refund(any(), any()); + } + } +} diff --git a/src/test/java/studio/magemonkey/fusion/crafting/QueueCancellationTest.java b/src/test/java/studio/magemonkey/fusion/crafting/QueueCancellationTest.java new file mode 100644 index 0000000..8b43f35 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/crafting/QueueCancellationTest.java @@ -0,0 +1,101 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; +import studio.magemonkey.codex.CodexEngine; +import studio.magemonkey.fusion.api.*; +import studio.magemonkey.fusion.api.events.QueueItemRemovedEvent; +import studio.magemonkey.fusion.api.events.services.QueueService; +import studio.magemonkey.fusion.cfg.ProfessionsCfg; +import studio.magemonkey.fusion.cfg.sql.SQLManager; +import studio.magemonkey.fusion.cfg.sql.tables.FusionQueuesSQL; +import studio.magemonkey.fusion.data.queue.*; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.data.recipes.Recipe; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class QueueCancellationTest { + MockedStatic bukkit; + MockedStatic api; + MockedStatic config; + MockedStatic sql; + MockedStatic codex; + Player player; + CraftingTable table; + CraftingQueue queue; + QueueItem item; + FusionQueuesSQL repository; + PluginManager plugins; + CodexEngine engine; + QueueService service = new QueueService(); + + @BeforeEach void setup() { + bukkit = mockStatic(Bukkit.class); api = mockStatic(FusionAPI.class); + config = mockStatic(ProfessionsCfg.class); sql = mockStatic(SQLManager.class); codex = mockStatic(CodexEngine.class); + plugins = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(any(ItemStack[].class))).thenReturn(new HashMap<>()); + table = mock(CraftingTable.class); + when(table.getName()).thenReturn("smith"); + config.when(() -> ProfessionsCfg.getTable("smith")).thenReturn(table); + PlayerManager manager = mock(PlayerManager.class); + api.when(FusionAPI::getPlayerManager).thenReturn(manager); + engine = mock(CodexEngine.class, RETURNS_DEEP_STUBS); + codex.when(CodexEngine::get).thenReturn(engine); + repository = mock(FusionQueuesSQL.class); + sql.when(SQLManager::queues).thenReturn(repository); + Recipe recipe = mock(Recipe.class, RETURNS_DEEP_STUBS); + when(recipe.getCraftingTime()).thenReturn(30); + when(recipe.getConditions().getMoneyCost()).thenReturn(999.0); // edited since charging + item = new QueueItem(42, "smith", null, recipe, 1000, 0); + item.setReceipt(new CraftingReceipt(List.of(), 7, 2.0)); + queue = mock(CraftingQueue.class); + when(queue.getQueue()).thenReturn(new ArrayList<>(List.of(item))); + } + @AfterEach void close() { codex.close(); sql.close(); config.close(); api.close(); bukkit.close(); } + + @Test void cancelledCraftRefundsRecordedCostsOnce() { + when(repository.removeQueueItem(item)).thenReturn(true); + ItemStack actual = mock(ItemStack.class); + when(actual.clone()).thenReturn(actual); + when(actual.getAmount()).thenReturn(3); + when(actual.getMaxStackSize()).thenReturn(64); + var result = service.cancelQueueItemAndReport(player, table, queue, item, false, true, List.of(actual)); + assertEquals(CraftingResult.SUCCESS, result); + verify(player).giveExp(7); + verify(engine.getVault()).give(player, 2.0); + verify(player.getInventory()).addItem(actual); + assertEquals(CraftingResult.NOT_FOUND, + service.cancelQueueItemAndReport(player, table, queue, item, false, true, List.of(actual))); + verify(repository, times(1)).removeQueueItem(item); + verify(player, times(1)).giveExp(7); + } + @Test void storageFailureKeepsCraftAndDoesNotRefund() { + when(repository.removeQueueItem(item)).thenReturn(false); + assertEquals(CraftingResult.STORAGE_FAILED, + service.cancelQueueItemAndReport(player, table, queue, item, false, true, List.of())); + assertTrue(queue.getQueue().contains(item)); + verify(player, never()).giveExp(anyInt()); + verify(engine.getVault(), never()).give(any(), anyDouble()); + } + @Test void eventCancellationIsRespectedBeforeAnyStorageOrRefund() { + doAnswer(invocation -> { ((QueueItemRemovedEvent) invocation.getArgument(0)).setCancelled(true); return null; }) + .when(plugins).callEvent(any(QueueItemRemovedEvent.class)); + assertEquals(CraftingResult.EVENT_CANCELLED, + service.cancelQueueItemAndReport(player, table, queue, item, false, true, List.of())); + verifyNoInteractions(repository); + assertTrue(queue.getQueue().contains(item)); + verify(player, never()).giveExp(anyInt()); + } +} diff --git a/src/test/java/studio/magemonkey/fusion/data/QueueProgressTest.java b/src/test/java/studio/magemonkey/fusion/data/QueueProgressTest.java new file mode 100644 index 0000000..0fc1c7b --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/data/QueueProgressTest.java @@ -0,0 +1,46 @@ +package studio.magemonkey.fusion.data; + +import org.junit.jupiter.api.Test; +import studio.magemonkey.fusion.data.queue.*; +import studio.magemonkey.fusion.data.recipes.Recipe; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class QueueProgressTest { + private QueueItem item(int seconds) { + Recipe recipe = mock(Recipe.class); + when(recipe.getCraftingTime()).thenReturn(seconds); + return new QueueItem(-1, "smith", null, recipe, 1000, 0); + } + @Test void countdownAdvancesWithoutGuiAndIgnoresVisualValues() { + QueueItem item = item(10); + item.setVisualRemainingItemTime(5000); + QueueProgress.advance(List.of(item), 1); + assertEquals(1, item.getSavedSeconds()); + assertEquals(9, item.getVisualRemainingItemTime()); + QueueProgress.advance(List.of(item), 1); + assertEquals(8, item.getVisualRemainingItemTime()); + } + @Test void advancesSequentiallyAcrossCompletionAndUpdatesWaitingEta() { + QueueItem first = item(2), second = item(5); + QueueProgress.advance(List.of(first, second), 1); + assertEquals(1, first.getRemainingSeconds()); + assertEquals(0, second.getSavedSeconds()); + assertEquals(6, second.getVisualRemainingItemTime()); + QueueProgress.advance(List.of(first, second), 3); + assertTrue(first.isDone()); + assertEquals(2, second.getSavedSeconds()); + assertEquals(3, second.getVisualRemainingItemTime()); + } + @Test void completionAndZeroDurationAreStableAndNegativeElapsedIsIgnored() { + QueueItem instant = item(0), regular = item(1); + QueueProgress.advance(List.of(instant, regular), -5); + assertTrue(instant.isDone()); + assertEquals(0, regular.getSavedSeconds()); + QueueProgress.advance(List.of(instant, regular), Long.MAX_VALUE); + assertTrue(regular.isDone()); + assertEquals(1, regular.getSavedSeconds()); + assertEquals(0, regular.getVisualRemainingItemTime()); + } +} diff --git a/src/test/java/studio/magemonkey/fusion/gui/GuiUpdateCoordinatorTest.java b/src/test/java/studio/magemonkey/fusion/gui/GuiUpdateCoordinatorTest.java new file mode 100644 index 0000000..df124bd --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/gui/GuiUpdateCoordinatorTest.java @@ -0,0 +1,30 @@ +package studio.magemonkey.fusion.gui; + +import org.junit.jupiter.api.Test; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.*; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.gui.recipe.GuiUpdateCoordinator; +import static org.mockito.Mockito.*; + +class GuiUpdateCoordinatorTest { + @Test void requestsCoalesceAndReopenCancelsOldTask() { + try (var bukkit = mockStatic(Bukkit.class); var fusion = mockStatic(Fusion.class)) { + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask first = mock(BukkitTask.class), second = mock(BukkitTask.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + Fusion plugin = mock(Fusion.class); + fusion.when(Fusion::getInstance).thenReturn(plugin); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenReturn(first, second); + Runnable refresh = mock(Runnable.class), pulse = mock(Runnable.class); + var coordinator = new GuiUpdateCoordinator(refresh, pulse); + coordinator.open(); + coordinator.request(); coordinator.request(); coordinator.tick(); + verify(refresh, times(1)).run(); + coordinator.open(); + verify(first).cancel(); + coordinator.close(); + verify(second).cancel(); + } + } +} diff --git a/src/test/java/studio/magemonkey/fusion/gui/PatternCompilerTest.java b/src/test/java/studio/magemonkey/fusion/gui/PatternCompilerTest.java new file mode 100644 index 0000000..fa4dfd7 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/gui/PatternCompilerTest.java @@ -0,0 +1,28 @@ +package studio.magemonkey.fusion.gui; + +import org.junit.jupiter.api.Test; +import studio.magemonkey.fusion.gui.recipe.*; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class PatternCompilerTest { + @Test void legacyRolesAndCustomSymbolsHaveSameLayout() { + var legacy = PatternCompiler.compile(new String[]{"fooooffff", "f---f{f}f"}, Map.of()); + var custom = PatternCompiler.compile(new String[]{"frrrrffff", "fqqqf{f}f"}, + Map.of('r', SlotRole.RECIPE_LIST, 'q', SlotRole.QUEUE_LIST)); + assertEquals(legacy.slots(SlotRole.RECIPE_LIST), custom.slots(SlotRole.RECIPE_LIST)); + assertEquals(legacy.slots(SlotRole.QUEUE_LIST), custom.slots(SlotRole.QUEUE_LIST)); + assertEquals(18, custom.size()); + } + @Test void rejectsMalformedAndIncompleteLayouts() { + assertThrows(IllegalArgumentException.class, () -> PatternCompiler.compile(new String[]{"ooo"}, Map.of())); + assertThrows(IllegalArgumentException.class, () -> PatternCompiler.compile(new String[]{"fffffffff"}, Map.of())); + assertThrows(IllegalArgumentException.class, () -> PatternCompiler.compile(new String[]{"offf{fff}"}, Map.of())); + assertThrows(IllegalArgumentException.class, () -> PatternCompiler.compile(new String[7], Map.of())); + } + @Test void paginationClampsEmptyAndShrinkingPages() { + assertEquals(0, RecipeGui.clampPage(5, 0, 3)); + assertEquals(1, RecipeGui.clampPage(2, 6, 3)); + assertEquals(0, RecipeGui.clampPage(4, 10, 0)); + } +} diff --git a/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java b/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java new file mode 100644 index 0000000..ae97bf0 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java @@ -0,0 +1,73 @@ +package studio.magemonkey.fusion.gui; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.Test; +import studio.magemonkey.fusion.cfg.ProfessionsCfg; +import studio.magemonkey.fusion.data.player.*; +import studio.magemonkey.fusion.data.professions.pattern.*; +import studio.magemonkey.fusion.data.queue.*; +import studio.magemonkey.fusion.data.recipes.*; +import studio.magemonkey.fusion.data.recipes.Recipe; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class QueuedRecipeGuiTest { + private Inventory inventory() { + Inventory inventory = mock(Inventory.class); + Map slots = new HashMap<>(); + when(inventory.getSize()).thenReturn(9); + when(inventory.getItem(anyInt())).thenAnswer(call -> slots.get(call.getArgument(0))); + doAnswer(call -> { slots.put(call.getArgument(0), call.getArgument(1)); return null; }) + .when(inventory).setItem(anyInt(), any()); + return inventory; + } + @Test void visibleCountdownChangesAndSeparateViewsDoNotShareSlotBindings() { + try (var bukkit = mockStatic(Bukkit.class); var loader = mockStatic(PlayerLoader.class); + var config = mockStatic(ProfessionsCfg.class)) { + Player player = mock(Player.class); + FusionPlayer owner = mock(FusionPlayer.class); + loader.when(() -> PlayerLoader.getPlayer(player)).thenReturn(owner); + CraftingTable table = mock(CraftingTable.class); + when(table.getName()).thenReturn("smith"); + when(table.getInventoryName()).thenReturn("Smith"); + InventoryPattern firstPattern = new InventoryPattern(new String[]{"o-fffffff"}, new HashMap<>()); + InventoryPattern secondPattern = new InventoryPattern(new String[]{"offffff-f"}, new HashMap<>()); + when(table.getRecipePattern()).thenReturn(firstPattern); + Category firstCategory = mock(Category.class), secondCategory = mock(Category.class); + when(firstCategory.getName()).thenReturn("tools"); when(secondCategory.getName()).thenReturn("tools"); + when(firstCategory.getPattern()).thenReturn(firstPattern); when(secondCategory.getPattern()).thenReturn(secondPattern); + Inventory firstInventory = inventory(), secondInventory = inventory(); + bukkit.when(() -> Bukkit.createInventory(isNull(), eq(9), eq("Smith"))).thenReturn(firstInventory, secondInventory); + CraftingQueue queue = mock(CraftingQueue.class); + when(owner.getQueue(eq("smith"), any())).thenReturn(queue); + Recipe recipe = mock(Recipe.class); + when(recipe.getCraftingTime()).thenReturn(10); + QueueItem item = new QueueItem(99, "smith", firstCategory, recipe, 1000, 0); + when(queue.getQueue()).thenReturn(new ArrayList<>(List.of(item))); + Map icons = new HashMap<>(); + config.when(() -> ProfessionsCfg.getQueueItem(eq("smith"), any())).thenAnswer(call -> { + int seconds = ((QueueItem) call.getArgument(1)).getVisualRemainingItemTime(); + return icons.computeIfAbsent(seconds, ignored -> { + ItemStack icon = mock(ItemStack.class); when(icon.clone()).thenReturn(icon); return icon; + }); + }); + QueuedRecipeGUI first = new QueuedRecipeGUI(player, table, firstCategory); + QueuedRecipeGUI second = new QueuedRecipeGUI(player, table, secondCategory); + first.onOpen(); second.onOpen(); + first.renderDynamic(); second.renderDynamic(); + assertSame(icons.get(10), firstInventory.getItem(1)); + assertSame(icons.get(10), secondInventory.getItem(7)); + clearInvocations(firstInventory); + first.renderDynamic(); + verify(firstInventory, never()).setItem(anyInt(), any()); + QueueProgress.advance(queue.getQueue(), 1); + first.renderDynamic(); second.renderDynamic(); + assertSame(icons.get(9), firstInventory.getItem(1)); + assertSame(icons.get(9), secondInventory.getItem(7)); + verify(queue, never()).getQueuedItems(); + } + } +} From f1b1861a8562c15e71dfd92e2daa637843d9132e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:26:47 +0000 Subject: [PATCH 4/4] fix instant collect limit result propagation and add limit regressions Co-authored-by: MaksyKun <77341370+MaksyKun@users.noreply.github.com> --- .../api/events/services/QueueService.java | 6 +- .../QueueServiceInstantCollectTest.java | 79 ++++++++++++ .../CraftingChecksLimitLifecycleTest.java | 114 ++++++++++++++++++ .../fusion/gui/QueuedRecipeGuiTest.java | 30 +++++ 4 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/test/java/studio/magemonkey/fusion/api/events/services/QueueServiceInstantCollectTest.java create mode 100644 src/test/java/studio/magemonkey/fusion/crafting/CraftingChecksLimitLifecycleTest.java 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 10a4920..083c41b 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 @@ -57,7 +57,11 @@ public CraftingResult addQueueItemResult( event.getQueue().getQueue().add(item); if (Cfg.instantCollect && item.getRecipe().getCraftingTime() <= 0) { item.markDone(); - event.getQueue().finishRecipe(item); + return finishQueueItemAndReport(player, + table, + event.getQueue(), + item, + item.getRecipe().getResults().getItems()); } return CraftingResult.SUCCESS; } diff --git a/src/test/java/studio/magemonkey/fusion/api/events/services/QueueServiceInstantCollectTest.java b/src/test/java/studio/magemonkey/fusion/api/events/services/QueueServiceInstantCollectTest.java new file mode 100644 index 0000000..4290102 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/api/events/services/QueueServiceInstantCollectTest.java @@ -0,0 +1,79 @@ +package studio.magemonkey.fusion.api.events.services; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; +import studio.magemonkey.fusion.api.FusionAPI; +import studio.magemonkey.fusion.api.PlayerManager; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.cfg.sql.SQLManager; +import studio.magemonkey.fusion.cfg.sql.tables.FusionQueuesSQL; +import studio.magemonkey.fusion.crafting.CraftingResult; +import studio.magemonkey.fusion.data.player.FusionPlayer; +import studio.magemonkey.fusion.data.queue.CraftingQueue; +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.data.recipes.RecipeItem; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class QueueServiceInstantCollectTest { + MockedStatic bukkit; + MockedStatic api; + MockedStatic sql; + boolean previousInstantCollect; + + @BeforeEach void setup() { + previousInstantCollect = Cfg.instantCollect; + Cfg.instantCollect = true; + bukkit = mockStatic(Bukkit.class); + api = mockStatic(FusionAPI.class); + sql = mockStatic(SQLManager.class); + PluginManager manager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + PlayerManager players = mock(PlayerManager.class); + FusionPlayer owner = mock(FusionPlayer.class); + when(players.getPlayer(any(Player.class))).thenReturn(owner); + api.when(FusionAPI::getPlayerManager).thenReturn(players); + } + + @AfterEach void close() { + Cfg.instantCollect = previousInstantCollect; + if (sql != null) sql.close(); + if (api != null) api.close(); + if (bukkit != null) bukkit.close(); + } + + @Test void instantCollectReturnsFinalizationResult() { + Player player = mock(Player.class); + CraftingTable table = mock(CraftingTable.class); + when(table.getName()).thenReturn("smith"); + CraftingQueue queue = mock(CraftingQueue.class); + when(queue.getQueue()).thenReturn(new ArrayList<>()); + Recipe recipe = mock(Recipe.class, RETURNS_DEEP_STUBS); + when(recipe.getCraftingTime()).thenReturn(0); + List result = List.of(mock(RecipeItem.class)); + when(recipe.getResults().getItems()).thenReturn(result); + QueueItem item = mock(QueueItem.class); + when(item.getRecipe()).thenReturn(recipe); + + FusionQueuesSQL queues = mock(FusionQueuesSQL.class); + when(queues.setQueueItem(any(), eq(item))).thenReturn(true); + sql.when(SQLManager::queues).thenReturn(queues); + + QueueService service = spy(new QueueService()); + doReturn(CraftingResult.REQUIREMENTS_NOT_MET).when(service) + .finishQueueItemAndReport(player, table, queue, item, result); + + assertEquals(CraftingResult.REQUIREMENTS_NOT_MET, service.addQueueItemResult(player, table, queue, item)); + verify(service).finishQueueItemAndReport(player, table, queue, item, result); + } +} diff --git a/src/test/java/studio/magemonkey/fusion/crafting/CraftingChecksLimitLifecycleTest.java b/src/test/java/studio/magemonkey/fusion/crafting/CraftingChecksLimitLifecycleTest.java new file mode 100644 index 0000000..39adc66 --- /dev/null +++ b/src/test/java/studio/magemonkey/fusion/crafting/CraftingChecksLimitLifecycleTest.java @@ -0,0 +1,114 @@ +package studio.magemonkey.fusion.crafting; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.data.player.FusionPlayer; +import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.player.PlayerRecipeLimit; +import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.data.queue.CraftingQueue; +import studio.magemonkey.fusion.data.queue.QueueItem; +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.util.PlayerUtil; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class CraftingChecksLimitLifecycleTest { + MockedStatic calculated; + MockedStatic loader; + MockedStatic permissions; + Player player; + CraftingTable table; + Category category; + Recipe recipe; + FusionPlayer owner; + CraftingQueue queue; + AtomicReference persistedLimit; + boolean previousInstantCollect; + + @BeforeEach void setup() { + previousInstantCollect = Cfg.instantCollect; + Cfg.instantCollect = false; + player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getContents()).thenReturn(new ItemStack[]{new ItemStack(Material.AIR)}); + table = mock(CraftingTable.class); + category = mock(Category.class); + recipe = mock(Recipe.class); + owner = mock(FusionPlayer.class); + queue = mock(CraftingQueue.class); + when(table.getName()).thenReturn("smith"); + when(category.getName()).thenReturn("tools"); + when(recipe.getRecipePath()).thenReturn("smith.tools.pick"); + when(recipe.getCraftingTime()).thenReturn(10); + when(queue.getCategory()).thenReturn(category); + when(queue.getQueue()).thenReturn(new ArrayList<>()); + when(owner.getQueueSizes("smith", category)).thenReturn(new int[]{0, 0, 0}); + persistedLimit = new AtomicReference<>(new PlayerRecipeLimit(recipe.getRecipePath(), 0, -1)); + when(owner.getRecipeLimit(recipe)).thenAnswer(invocation -> persistedLimit.get()); + when(owner.hasRecipeLimitReached(recipe)).thenReturn(false); + + calculated = mockStatic(CalculatedRecipe.class); + CalculatedRecipe calculatedRecipe = mock(CalculatedRecipe.class); + when(calculatedRecipe.isCanCraft()).thenReturn(true); + calculated.when(() -> CalculatedRecipe.create(eq(recipe), anyMap(), eq(player), eq(table))).thenReturn(calculatedRecipe); + + loader = mockStatic(PlayerLoader.class); + loader.when(() -> PlayerLoader.getPlayer(player)).thenReturn(owner); + permissions = mockStatic(PlayerUtil.class); + permissions.when(() -> PlayerUtil.getPermOption(any(), anyString())).thenReturn(0); + } + + @AfterEach void close() { + Cfg.instantCollect = previousInstantCollect; + if (permissions != null) permissions.close(); + if (loader != null) loader.close(); + if (calculated != null) calculated.close(); + } + + @Test void limitMinusOneAllowsQueueing() { + when(recipe.getCraftingLimit()).thenReturn(2); + persistedLimit.set(new PlayerRecipeLimit(recipe.getRecipePath(), 1, -1)); + assertTrue(CraftingChecks.canCraft(player, table, recipe, queue)); + } + + @Test void exactLimitBlocksQueueing() { + when(recipe.getCraftingLimit()).thenReturn(2); + persistedLimit.set(new PlayerRecipeLimit(recipe.getRecipePath(), 2, -1)); + when(owner.hasRecipeLimitReached(recipe)).thenReturn(true); + assertFalse(CraftingChecks.canCraft(player, table, recipe, queue)); + } + + @Test void finishedButUncollectedCraftCountsOnceTowardLimit() { + when(recipe.getCraftingLimit()).thenReturn(3); + persistedLimit.set(new PlayerRecipeLimit(recipe.getRecipePath(), 1, -1)); + QueueItem finished = new QueueItem(11, "smith", category, recipe, System.currentTimeMillis(), recipe.getCraftingTime()); + when(queue.getQueue()).thenReturn(new ArrayList<>(java.util.List.of(finished))); + assertTrue(CraftingChecks.canCraft(player, table, recipe, queue)); + when(recipe.getCraftingLimit()).thenReturn(2); + assertFalse(CraftingChecks.canCraft(player, table, recipe, queue)); + } + + @Test void instantCollectStillHonorsExactLimitSemantics() { + Cfg.instantCollect = true; + when(recipe.getCraftingTime()).thenReturn(0); + when(recipe.getCraftingLimit()).thenReturn(2); + persistedLimit.set(new PlayerRecipeLimit(recipe.getRecipePath(), 1, -1)); + QueueItem queued = new QueueItem(15, "smith", category, recipe, System.currentTimeMillis(), 0); + when(queue.getQueue()).thenReturn(new ArrayList<>(java.util.List.of(queued))); + assertFalse(CraftingChecks.canCraft(player, table, recipe, queue)); + } +} diff --git a/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java b/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java index ae97bf0..181b91e 100644 --- a/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java +++ b/src/test/java/studio/magemonkey/fusion/gui/QueuedRecipeGuiTest.java @@ -70,4 +70,34 @@ private Inventory inventory() { verify(queue, never()).getQueuedItems(); } } + + @Test void shiftMulticraftingStopsAtFirstFailure() { + Player player = mock(Player.class); + CraftingTable table = mock(CraftingTable.class); + Category category = mock(Category.class); + CraftingQueue queue = mock(CraftingQueue.class); + Recipe recipe = mock(Recipe.class); + FusionPlayer owner = mock(FusionPlayer.class); + when(table.getName()).thenReturn("smith"); + when(table.getInventoryName()).thenReturn("Smith"); + when(table.getRecipePattern()).thenReturn(new InventoryPattern(new String[]{"offfffffff"}, new HashMap<>())); + when(category.getName()).thenReturn("tools"); + when(category.getPattern()).thenReturn(table.getRecipePattern()); + when(owner.getQueue("smith", category)).thenReturn(queue); + when(queue.getQueue()).thenReturn(new ArrayList<>()); + try (var bukkit = mockStatic(Bukkit.class); + var loader = mockStatic(PlayerLoader.class); + var services = mockConstruction(QueueCraftingService.class, (mock, context) -> + when(mock.enqueue(player, table, queue, recipe)) + .thenReturn(CraftingResult.SUCCESS, CraftingResult.SUCCESS, CraftingResult.REQUIREMENTS_NOT_MET))) { + bukkit.when(() -> Bukkit.createInventory(isNull(), eq(9), eq("Smith"))).thenReturn(inventory()); + loader.when(() -> PlayerLoader.getPlayer(player)).thenReturn(owner); + QueuedRecipeGUI gui = new QueuedRecipeGUI(player, table, category); + gui.onOpen(); + gui.onCraft(recipe, true); + QueueCraftingService service = services.constructed().get(0); + verify(service, times(3)).enqueue(player, table, queue, recipe); + verifyNoMoreInteractions(service); + } + } }