diff --git a/pom.xml b/pom.xml index 6e5e13a..fd07921 100644 --- a/pom.xml +++ b/pom.xml @@ -64,7 +64,7 @@ studio.magemonkey divinity - 1.0.2-R0.47-SNAPSHOT + 1.0.2-R0.57-SNAPSHOT studio.magemonkey diff --git a/src/main/java/studio/magemonkey/fusion/Fusion.java b/src/main/java/studio/magemonkey/fusion/Fusion.java index 18799e3..01fbdd6 100644 --- a/src/main/java/studio/magemonkey/fusion/Fusion.java +++ b/src/main/java/studio/magemonkey/fusion/Fusion.java @@ -17,6 +17,7 @@ import studio.magemonkey.codex.util.ItemUtils; import studio.magemonkey.fusion.api.FusionAPI; import studio.magemonkey.fusion.cfg.*; +import studio.magemonkey.fusion.cfg.FuelManager; import studio.magemonkey.fusion.cfg.editors.EditorRegistry; import studio.magemonkey.fusion.cfg.hooks.HookManager; import studio.magemonkey.fusion.cfg.hooks.HookType; @@ -70,6 +71,7 @@ public void reloadConfig() { hookManager = new HookManager(); Cfg.init(); + FuelManager.init(); Bukkit.getScheduler().runTaskAsynchronously(this, () -> { ProfessionsCfg.init(); EditorRegistry.reload(); diff --git a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java index e76f3e4..0f6510c 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/Cfg.java @@ -7,6 +7,7 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.api.FusionAPI; import studio.magemonkey.fusion.cfg.sql.DatabaseType; import studio.magemonkey.fusion.commands.CommandMechanics; import studio.magemonkey.fusion.data.player.FusionPlayer; @@ -17,6 +18,7 @@ import java.io.IOException; import java.sql.Array; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -41,6 +43,11 @@ public final class Cfg { public static List disabledVanillaRecipes = new ArrayList<>(); public static List autoJoinProfessions = new ArrayList<>(); + // Fuel system + public static int fuelStart = 0; + public static int fuelMax = 1000; + public static List fuelItems = new ArrayList<>(); + // No usage inside of Cfg, just used for default values. The actual values are stored in SQLManager.class private static final DatabaseType storageType = DatabaseType.LOCAL; private static final String storageHost = "localhost"; @@ -103,6 +110,19 @@ private static void addDefs(FileConfiguration cfg) { if (!cfg.isSet("useCustomFormula")) cfg.set("useCustomFormula", useCustomFormula); if (!cfg.isSet("disabled_vanilla_recipes")) cfg.set("disabled_vanilla_recipes", disabledVanillaRecipes); if (!cfg.isSet("auto_join_professions")) cfg.set("auto_join_professions", autoJoinProfessions); + + // Fuel defaults + if (!cfg.isSet("fuel.start")) cfg.set("fuel.start", 0); + if (!cfg.isSet("fuel.max")) cfg.set("fuel.max", 1000); + if (!cfg.isSet("fuel.items.coal.material")) cfg.set("fuel.items.coal.material", "COAL"); + if (!cfg.isSet("fuel.items.coal.amount")) cfg.set("fuel.items.coal.amount", 10); + if (!cfg.isSet("fuel.items.coal.return")) cfg.set("fuel.items.coal.return", "null"); + if (!cfg.isSet("fuel.items.coal_block.material")) cfg.set("fuel.items.coal_block.material", "COAL_BLOCK"); + if (!cfg.isSet("fuel.items.coal_block.amount")) cfg.set("fuel.items.coal_block.amount", 90); + if (!cfg.isSet("fuel.items.coal_block.return")) cfg.set("fuel.items.coal_block.return", "null"); + if (!cfg.isSet("fuel.items.blaze_rod.material")) cfg.set("fuel.items.blaze_rod.material", "BLAZE_ROD"); + if (!cfg.isSet("fuel.items.blaze_rod.amount")) cfg.set("fuel.items.blaze_rod.amount", 50); + if (!cfg.isSet("fuel.items.blaze_rod.return")) cfg.set("fuel.items.blaze_rod.return", "BLAZE_POWDER"); } public static void init() { @@ -129,6 +149,36 @@ public static void init() { disabledVanillaRecipes = BukkitRecipeWrapper.getRecipeKeysForMaterials(materials); autoJoinProfessions = cfg.getStringList("auto_join_professions"); + // Load fuel config + fuelStart = cfg.getInt("fuel.start", 0); + fuelMax = cfg.getInt("fuel.max", 1000); + fuelItems = new ArrayList<>(); + if (cfg.isConfigurationSection("fuel.items")) { + for (String key : cfg.getConfigurationSection("fuel.items").getKeys(false)) { + String path = "fuel.items." + key; + String matName = cfg.getString(path + ".material", ""); + int fuelAmount = cfg.getInt(path + ".amount", 0); + String retName = cfg.getString(path + ".return", "null"); + + Material mat; + try { + mat = Material.valueOf(matName.toUpperCase()); + } catch (IllegalArgumentException e) { + Fusion.getInstance().getLogger().warning("Unknown material '" + matName + "' in fuel.items." + key); + continue; + } + Material ret = null; + if (retName != null && !retName.equalsIgnoreCase("null") && !retName.isEmpty()) { + try { + ret = Material.valueOf(retName.toUpperCase()); + } catch (IllegalArgumentException e) { + Fusion.getInstance().getLogger().warning("Unknown return material '" + retName + "' in fuel.items." + key); + } + } + fuelItems.add(new FuelItem(key, mat, fuelAmount, ret)); + } + } + migrateOldTypes(cfg); } @@ -188,8 +238,9 @@ public static boolean setDatabaseType(DatabaseType type) { public static void autoJoinProfessions(Player player) { FusionPlayer fp = PlayerLoader.getPlayer(player); for (String professionId : autoJoinProfessions) { - if(fp.hasProfession(professionId) && fp.hasJoined(professionId)) continue; - BrowseGUI.joinProfession(player, ProfessionsCfg.getGuiMap().get(professionId)); + if (fp.hasProfession(professionId) && fp.hasJoined(professionId)) continue; + if (ProfessionsCfg.getTable(professionId) == null) continue; + FusionAPI.getEventServices().getProfessionService().joinProfession(professionId, player, 0.0, 0); } } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java index e593a34..7efbee7 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/CraftingRequirementsCfg.java @@ -122,7 +122,17 @@ public static String getCanCraft(boolean fulfilled) { return ChatUT.hexString(config.getString("recipes.canCraft." + (fulfilled ? "true" : "false"), (fulfilled ? "&aYou can craft this item." : "&cYou can't craft this item."))); } - + public static String getStationLine(String path, boolean fulfilled, String stationName) { + String displayName = stationName; + // Jeśli to divinity, pokaż ładniejszą nazwę + if (stationName.toLowerCase().startsWith("divinity:")) { + displayName = "Divinity: " + stationName.substring("divinity:".length()); + } + String line = config.getString(path + ".station." + (fulfilled ? "true" : "false"), + fulfilled ? "&6- &eStation: &7(&a" + displayName + "&7)" + : "&6- &eStation: &7(&c" + displayName + "&7)"); + return ChatUT.hexString(line); + } public static String getLimit(String path, int limit, int maxLimit) { boolean fulfilled = limit < maxLimit; String line = config.getString(path + ".limit." + (fulfilled ? "true" : "false"), diff --git a/src/main/java/studio/magemonkey/fusion/cfg/FuelItem.java b/src/main/java/studio/magemonkey/fusion/cfg/FuelItem.java new file mode 100644 index 0000000..105e0fc --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/cfg/FuelItem.java @@ -0,0 +1,56 @@ +package studio.magemonkey.fusion.cfg; + +import lombok.Getter; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.List; + +/** + * Represents one type of item that can be used as fuel in the server fuel tank. + */ +@Getter +public class FuelItem { + + private final String id; // config key (e.g. "coal") + private final Material material; // item material + private final int fuelAmount; // fuel added per item consumed + private final Material returnMaterial; // item returned after use (null = nothing) + + public FuelItem(String id, Material material, int fuelAmount, Material returnMaterial) { + this.id = id; + this.material = material; + this.fuelAmount = fuelAmount; + this.returnMaterial = returnMaterial; + } + + /** + * Creates a display ItemStack for use in the FuelGUI. + */ + public ItemStack createDisplayItem() { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.YELLOW + id); + meta.setLore(List.of( + ChatColor.GRAY + "Fuel per item: " + ChatColor.WHITE + fuelAmount, + returnMaterial != null + ? ChatColor.GRAY + "Returns: " + ChatColor.WHITE + returnMaterial.name() + : ChatColor.DARK_GRAY + "No return item", + "", + ChatColor.GREEN + "Click to add fuel from inventory" + )); + item.setItemMeta(meta); + } + return item; + } + + /** + * Returns true if the given ItemStack matches this fuel item's material. + */ + public boolean matches(ItemStack stack) { + return stack != null && stack.getType() == material; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/cfg/FuelManager.java b/src/main/java/studio/magemonkey/fusion/cfg/FuelManager.java new file mode 100644 index 0000000..8a87ac6 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/cfg/FuelManager.java @@ -0,0 +1,157 @@ +package studio.magemonkey.fusion.cfg; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import studio.magemonkey.fusion.Fusion; + +import java.io.File; +import java.io.IOException; + +/** + * Global per-server fuel counter. + * + * Configuration (config.yml): + * fuel.start — initial fuel on first start + * fuel.max — maximum capacity + * fuel.items — items that can be used as fuel (loaded into Cfg.fuelItems) + * + * Persistence (fuel.yml): + * current — current fuel level (updated live) + */ +public final class FuelManager { + + private static int current = 0; + + private FuelManager() {} + + // ────────────────────────────────────────────── + // Initialisation — called from Fusion.reloadConfig() + // ────────────────────────────────────────────── + + public static void init() { + // Load current level from fuel.yml; use Cfg.fuelStart as default + FileConfiguration saved = loadFuelFile(); + current = saved.getInt("current", Cfg.fuelStart); + + // Clamp to valid range from config + if (current < 0) current = 0; + if (current > Cfg.fuelMax) current = Cfg.fuelMax; + } + + // ────────────────────────────────────────────── + // fuel.yml helpers (stores only current level) + // ────────────────────────────────────────────── + + private static File getFuelFile() { + return new File(Fusion.getInstance().getDataFolder(), "fuel.yml"); + } + + private static FileConfiguration loadFuelFile() { + File file = getFuelFile(); + FileConfiguration cfg = new YamlConfiguration(); + if (file.exists()) { + try { + cfg.load(file); + } catch (Exception e) { + Fusion.getInstance().getLogger().severe("Could not load fuel.yml: " + e.getMessage()); + } + } + return cfg; + } + + public static void save() { + File file = getFuelFile(); + FileConfiguration cfg = new YamlConfiguration(); + cfg.set("current", current); + try { + file.getParentFile().mkdirs(); + cfg.save(file); + } catch (IOException e) { + Fusion.getInstance().getLogger().severe("Could not save fuel.yml: " + e.getMessage()); + } + } + + // ────────────────────────────────────────────── + // API + // ────────────────────────────────────────────── + + public static int getFuel() { + return current; + } + + public static int getMaxFuel() { + return Cfg.fuelMax; + } + + public static boolean hasFuel(int cost) { + if (cost <= 0) return true; + return current >= cost; + } + + /** + * Consumes {@code cost} fuel. Returns true if successful, false if not enough. + */ + public static boolean consumeFuel(int cost) { + if (cost <= 0) return true; + if (current < cost) return false; + current -= cost; + save(); + return true; + } + + /** + * Adds up to {@code amount} fuel (capped at max). Returns the actual amount added. + */ + public static int addFuel(int amount) { + if (amount <= 0) return 0; + int space = Cfg.fuelMax - current; + int added = Math.min(amount, space); + current += added; + save(); + return added; + } + + /** + * Sets fuel directly, clamped to [0, max]. + */ + public static void setFuel(int amount) { + current = Math.max(0, Math.min(amount, Cfg.fuelMax)); + save(); + } + + /** + * Attempts to add fuel using one of the configured fuel items from the player's inventory. + * Takes one matching item, adds its fuel amount, gives return item if configured. + * Returns the FuelItem used, or null if the player had none of the given item. + */ + public static FuelItem addFuelFromInventory(Player player, FuelItem fuelItem) { + // Check inventory for a matching item + ItemStack[] contents = player.getInventory().getContents(); + for (int i = 0; i < contents.length; i++) { + ItemStack stack = contents[i]; + if (fuelItem.matches(stack)) { + // Take 1 of the item + if (stack.getAmount() == 1) { + player.getInventory().setItem(i, null); + } else { + stack.setAmount(stack.getAmount() - 1); + } + + // Add fuel + addFuel(fuelItem.getFuelAmount()); + + // Give return item + if (fuelItem.getReturnMaterial() != null) { + player.getInventory().addItem(new ItemStack(fuelItem.getReturnMaterial(), 1)) + .values() + .forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); + } + + return fuelItem; + } + } + return null; // player didn't have the item + } +} diff --git a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionLevelCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionLevelCfg.java index c456510..24a422e 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionLevelCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionLevelCfg.java @@ -45,7 +45,7 @@ public ProfessionLevelCfg(CraftingTable table, String filePath) { } public double getXP(int level) { - return levelMap.get(level); + return levelMap.getOrDefault(level, 0.0); } public int getLevel(double xp) { diff --git a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java index ec69b80..a87a8d5 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/ProfessionsCfg.java @@ -19,6 +19,7 @@ import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; import studio.magemonkey.fusion.hook.NexoHook; import studio.magemonkey.fusion.util.ChatUT; +import studio.magemonkey.fusion.util.TabCacher; import studio.magemonkey.fusion.util.Utils; import java.io.File; @@ -73,6 +74,7 @@ public static boolean createNewProfession(String profession, String refProfessio map.put(ct.getName(), ct); cfgs.put(profession, cfg); files.put(profession, file); + TabCacher.clearAllCaches("professions"); return true; } else if (refProfession == null) { files.put(profession, @@ -92,6 +94,7 @@ public static boolean createNewProfession(String profession, String refProfessio map.put(ct.getName(), ct); files.put(profession, file); cfgs.put(profession, cfg); + TabCacher.clearAllCaches("professions"); return true; } } catch (Exception e) { @@ -138,6 +141,8 @@ private static void loadProfessions(File root) { String key = entry.getKey(); guiMap.put(key, new ProfessionGuiRegistry(key)); } + + TabCacher.clearAllCaches("professions"); } public static CraftingTable getTable(String str) { diff --git a/src/main/java/studio/magemonkey/fusion/cfg/ShowRecipesCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/ShowRecipesCfg.java index 990d505..dde8f3d 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/ShowRecipesCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/ShowRecipesCfg.java @@ -63,7 +63,7 @@ public static void load() { private static void setDefaults() { // Show Recipe: GUI Mechanic - config.addDefault("name", "&8Recipes for ingredient: &a&7"); + config.addDefault("name", "&8Recipes for ingredient: &a&7$"); HashMap showItems = new HashMap<>(); showItems.put('0', ItemBuilder.newItem(Material.BIRCH_SIGN) .name("&8Possible Recipes") @@ -81,8 +81,8 @@ private static void setDefaults() { HashMap recipeItem = new HashMap<>(); recipeItem.put("material", "$"); - recipeItem.put("name", "&7"); - recipeItem.put("lore", new String[]{"&8[&a$x &7&8]", "&7Click to navigate to recipe"}); + recipeItem.put("name", "&7$"); + recipeItem.put("lore", new String[]{"&8[&a$x &7$&8]", "&7Click to navigate to recipe"}); config.addDefault("recipeItem", recipeItem); config.addDefault("fillItem", ItemBuilder.newItem(Material.BLACK_STAINED_GLASS_PANE).name(" ").build()); @@ -97,14 +97,17 @@ private static void readData() { public static ItemStack getRecipeIcon(Recipe recipe, RecipeItem ingredient) { String itemName = Utils.getItemName(recipe.getSettings().getRecipeItem().getItemStack()); + String ingredientName = Utils.getItemName(ingredient.getItemStack()); + String amountStr = String.valueOf(ingredient.getAmount()); + String professionName = recipe.getTable().getInventoryName(); String name = ChatUT.hexString(config.getString("recipeItem.name", "&7$") - .replace(MessageUtil.getReplacement("name"), itemName)); + .replace("$", itemName).replace("", itemName)); List lore = config.getStringList("recipeItem.lore"); - lore.replaceAll(s -> ChatUT.hexString(s.replace(MessageUtil.getReplacement("ingredient"), - Utils.getItemName(ingredient.getItemStack())) - .replace(MessageUtil.getReplacement("profession"), recipe.getTable().getInventoryName()) - .replace(MessageUtil.getReplacement("amount"), String.valueOf(ingredient.getAmount())) - .replace(MessageUtil.getReplacement("name"), name))); + lore.replaceAll(s -> ChatUT.hexString(s + .replace("$", ingredientName).replace("", ingredientName) + .replace("$", professionName).replace("", professionName) + .replace("$", amountStr).replace("", amountStr) + .replace("$", name).replace("", name))); ItemStack icon = recipe.getSettings().getRecipeItem().getItemStack().clone(); ItemMeta meta = icon.getItemMeta(); @@ -115,7 +118,8 @@ public static ItemStack getRecipeIcon(Recipe recipe, RecipeItem ingredient) { } public static String getInventoryName(RecipeItem ingredient) { - return ChatUT.hexString(name.replace(MessageUtil.getReplacement("ingredient"), - Utils.getItemName(ingredient.getItemStack()))); + String ingredientName = Utils.getItemName(ingredient.getItemStack()); + return ChatUT.hexString(name + .replace("$", ingredientName).replace("", ingredientName)); } } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorCriteria.java b/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorCriteria.java index ad725ed..4d23042 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorCriteria.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorCriteria.java @@ -28,6 +28,8 @@ public enum EditorCriteria { Profession_Recipe_Add_Items, Profession_Recipe_Add_Conditions, Profession_Recipe_Edit_Permission, + Profession_Recipe_Edit_Station, + Profession_Recipe_Edit_FuelCost, // Recipe Icon RecipeIcon_Edit_Name, diff --git a/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorRegistry.java b/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorRegistry.java index e9c5427..a10c917 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorRegistry.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/editors/EditorRegistry.java @@ -16,6 +16,9 @@ import studio.magemonkey.fusion.gui.editors.browse.BrowseEditor; import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; +import org.bukkit.inventory.Inventory; + +import java.util.HashMap; import java.util.LinkedList; import java.util.Map; import java.util.TreeMap; @@ -23,7 +26,8 @@ public class EditorRegistry { - private static final Map editors = new TreeMap<>(); + private static final Map editors = new TreeMap<>(); + private static final Map inventoryEditorMap = new HashMap<>(); /* Main Editor Configs */ @Getter @@ -46,8 +50,21 @@ public class EditorRegistry { private static BrowseProfessionCfg browseProfessionCfg; + public static void registerInventory(Inventory inventory, Editor editor) { + inventoryEditorMap.put(inventory, editor); + } + + public static void unregisterInventory(Inventory inventory) { + inventoryEditorMap.remove(inventory); + } + + public static Editor getEditorByInventory(Inventory inventory) { + return inventoryEditorMap.get(inventory); + } + public static void reload() { editors.clear(); + inventoryEditorMap.clear(); professionEditorCfg = new ProfessionEditorCfg(); recipeEditorCfg = new RecipeEditorCfg(); @@ -62,8 +79,16 @@ public static void reload() { } public static Editor getProfessionEditor(Player player, String profession) { - if (!editors.containsKey(player.getUniqueId()) && profession != null) + Editor existing = editors.get(player.getUniqueId()); + if (existing instanceof ProfessionEditor pe && profession != null && !pe.getProfession().equals(profession)) { + pe.saveNow(); + pe.dispose(); + editors.remove(player.getUniqueId()); + existing = null; + } + if (existing == null && profession != null) { editors.put(player.getUniqueId(), new ProfessionEditor(player, profession)); + } return editors.get(player.getUniqueId()); } diff --git a/src/main/java/studio/magemonkey/fusion/cfg/editors/browse/BrowseProfessionCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/editors/browse/BrowseProfessionCfg.java index 8352398..d59c4de 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/editors/browse/BrowseProfessionCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/editors/browse/BrowseProfessionCfg.java @@ -86,7 +86,7 @@ public ItemStack getProfessionIcon(ProfessionConditions conditions) { .replace(MessageUtil.getReplacement("ingredient.name"), itemName) .replace(MessageUtil.getReplacement("ingredient.amount"), - String.valueOf(item.getItemStack().getAmount()))); + String.valueOf(patternItem.getAmount()))); newLines++; } i += newLines; @@ -170,7 +170,7 @@ public ItemStack getIcon(ProfessionConditions conditions, String icon) { .replace(MessageUtil.getReplacement("ingredient.name"), itemName) .replace(MessageUtil.getReplacement("ingredient.amount"), - String.valueOf(item.getItemStack().getAmount()))); + String.valueOf(patternItem.getAmount()))); newLines++; } i += newLines; diff --git a/src/main/java/studio/magemonkey/fusion/cfg/editors/professions/RecipeEditorCfg.java b/src/main/java/studio/magemonkey/fusion/cfg/editors/professions/RecipeEditorCfg.java index 86dbb27..a80018e 100644 --- a/src/main/java/studio/magemonkey/fusion/cfg/editors/professions/RecipeEditorCfg.java +++ b/src/main/java/studio/magemonkey/fusion/cfg/editors/professions/RecipeEditorCfg.java @@ -101,6 +101,9 @@ public ItemStack getRecipeIcon(Recipe recipe) { ); } + if (result == null || result.getType().isAir() || result.getType() == org.bukkit.Material.AIR) { + result = new ItemStack(org.bukkit.Material.PAPER); + } return ItemBuilder.newItem(result) .name(name) .lore(lore) @@ -116,19 +119,23 @@ public String getSubTitle(String recipeName) { public Map getSubIcons(Recipe recipe) { Map icons = new HashMap<>(); + ItemStack resultItem = recipe.getSettings().getRecipeItem().getItemStack(); for (String icon : config.getConfigurationSection("subEditor.icons").getKeys(false)) { - icons.put(icon, getSubIcon(recipe, icon)); + icons.put(icon, getSubIcon(recipe, icon, resultItem)); } return icons; } public ItemStack getSubIcon(Recipe recipe, String icon) { - ItemStack result = recipe.getSettings().getRecipeItem().getItemStack(); + return getSubIcon(recipe, icon, recipe.getSettings().getRecipeItem().getItemStack()); + } + + public ItemStack getSubIcon(Recipe recipe, String icon, ItemStack result) { String resultName = recipe.getSettings().getIconNamespace(); Material material = Material.valueOf(config.getString("subEditor.icons." + icon + ".material", "$") .replace(MessageUtil.getReplacement("material"), - recipe.getSettings().getRecipeItem().getItemStack().getType().name()) + result.getType().name()) .toUpperCase()); int amount = config.getInt("subEditor.icons." + icon + ".amount", 1); int durability = config.getInt("subEditor.icons." + icon + ".durability", 0); @@ -191,7 +198,7 @@ public ItemStack getSubIcon(Recipe recipe, String icon) { .replace(MessageUtil.getReplacement("ingredient.name"), itemName) .replace(MessageUtil.getReplacement("ingredient.amount"), - String.valueOf(item.getItemStack().getAmount()))); + String.valueOf(patternItem.getAmount()))); newLines++; } i += newLines; @@ -238,6 +245,11 @@ public ItemStack getSubIcon(Recipe recipe, String icon) { String.valueOf(recipe.getConditions().isMastery())) .replace(MessageUtil.getReplacement("conditions.permission"), String.valueOf(recipe.getConditions().getPermission())) + .replace(MessageUtil.getReplacement("conditions.station"), + recipe.getConditions().getStation() == null ? getUnsetFormat() + : recipe.getConditions().getStation()) + .replace(MessageUtil.getReplacement("conditions.fuelCost"), + String.valueOf(recipe.getConditions().getFuelCost())) .replace(MessageUtil.getReplacement("category"), recipe.getCategory() == null ? "master" : recipe.getCategory())) .replace(MessageUtil.getReplacement("hidings.noPermission"), diff --git a/src/main/java/studio/magemonkey/fusion/cfg/hooks/divinity/DivinityModuleItemType.java b/src/main/java/studio/magemonkey/fusion/cfg/hooks/divinity/DivinityModuleItemType.java new file mode 100644 index 0000000..c243a1b --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/cfg/hooks/divinity/DivinityModuleItemType.java @@ -0,0 +1,31 @@ +package studio.magemonkey.fusion.cfg.hooks.divinity; + +import org.bukkit.inventory.ItemStack; +import studio.magemonkey.codex.api.items.ItemType; +import studio.magemonkey.divinity.modules.ModuleItem; +import studio.magemonkey.divinity.utils.DivinityProvider; +import studio.magemonkey.divinity.utils.LoreUT; + +/** + * ItemType wrapper for DIV_ module-prefixed items (ITEMGEN, GEM, ESSENCE, RUNE, EXTRACTOR, + * ARROW, CONSUMABLE, CUSTOM, FORTIFY, IDENTIFY, DUST, REPAIR, DISMANTLE, REFINE). + * Extends DivinityItemType to support optional enchantment stripping (noEnch) for generator items. + */ +public class DivinityModuleItemType extends DivinityProvider.DivinityItemType { + + private final boolean noEnch; + + public DivinityModuleItemType(ModuleItem moduleItem, int level, ItemType material, boolean noEnch) { + super(moduleItem, level, material); + this.noEnch = noEnch; + } + + @Override + public ItemStack create() { + ItemStack item = super.create(); + if (noEnch) { + LoreUT.removeEnchants(item); + } + return item; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java index 886576e..0a67ff2 100644 --- a/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java +++ b/src/main/java/studio/magemonkey/fusion/commands/CommandMechanics.java @@ -10,17 +10,21 @@ import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.api.FusionAPI; import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.cfg.FuelManager; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.cfg.sql.DatabaseType; import studio.magemonkey.fusion.cfg.sql.SQLManager; import studio.magemonkey.fusion.data.player.PlayerLoader; import studio.magemonkey.fusion.data.professions.Profession; import studio.magemonkey.fusion.data.professions.pattern.Category; +import studio.magemonkey.fusion.cfg.hooks.divinity.DivinityModuleItemType; 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.RecipeCustomItem; import studio.magemonkey.fusion.data.recipes.RecipeItem; import studio.magemonkey.fusion.gui.BrowseGUI; +import studio.magemonkey.fusion.gui.FuelGUI; import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; import studio.magemonkey.fusion.gui.RecipeGui; import studio.magemonkey.fusion.gui.show.ShowRecipesGui; @@ -84,7 +88,7 @@ public static void useProfession(CommandSender sender, String[] args) { return; } //Make sure they have unlocked this crafting menu - if (!PlayerLoader.getPlayer(player).hasProfession(eq.getProfession())) { + if (!PlayerLoader.getPlayer(player).hasJoined(eq.getProfession())) { if (player.isOp()) { openGui(player, eq, category); CodexEngine.get().getMessageUtil().sendMessage("fusion.useConfirm", @@ -308,6 +312,67 @@ public static void toggleAutoCrafting(CommandSender sender) { .sendMessage("fusion.autoToggle", player, new MessageData("state", autoOn ? "on" : "off")); } + /** + * Handles /craft fuel [add|remove|set|check|gui] [amount] + * Permission: fusion.admin + */ + public static void handleFuel(CommandSender sender, String[] args) { + if (!Fusion.getInstance().checkPermission(sender, "fusion.admin")) return; + + // /craft fuel (no subcommand → show status) + if (args.length < 2 || args[1].equalsIgnoreCase("check")) { + sender.sendMessage(org.bukkit.ChatColor.GOLD + "[Fuel] " + org.bukkit.ChatColor.YELLOW + + "Current: " + org.bukkit.ChatColor.WHITE + FuelManager.getFuel() + + org.bukkit.ChatColor.YELLOW + " / " + org.bukkit.ChatColor.WHITE + FuelManager.getMaxFuel()); + return; + } + + // /craft fuel gui — open fuel GUI (players only) + if (args[1].equalsIgnoreCase("gui")) { + if (!(sender instanceof Player player)) { + sender.sendMessage(org.bukkit.ChatColor.RED + "This command can only be used by players."); + return; + } + FuelGUI.open(player); + return; + } + + // All remaining subcommands require an amount argument + if (args.length < 3) { + sender.sendMessage(org.bukkit.ChatColor.RED + "Usage: /craft fuel [amount]"); + return; + } + + int amount; + try { + amount = Integer.parseInt(args[2]); + } catch (NumberFormatException e) { + sender.sendMessage(org.bukkit.ChatColor.RED + "Invalid amount: " + args[2]); + return; + } + + switch (args[1].toLowerCase()) { + case "add" -> { + int added = FuelManager.addFuel(amount); + sender.sendMessage(org.bukkit.ChatColor.GREEN + "[Fuel] Added " + added + + ". New total: " + FuelManager.getFuel() + "/" + FuelManager.getMaxFuel()); + } + case "remove" -> { + int before = FuelManager.getFuel(); + FuelManager.setFuel(before - amount); + sender.sendMessage(org.bukkit.ChatColor.GREEN + "[Fuel] Removed " + (before - FuelManager.getFuel()) + + ". New total: " + FuelManager.getFuel() + "/" + FuelManager.getMaxFuel()); + } + case "set" -> { + FuelManager.setFuel(amount); + sender.sendMessage(org.bukkit.ChatColor.GREEN + "[Fuel] Set to " + FuelManager.getFuel() + + "/" + FuelManager.getMaxFuel()); + } + default -> sender.sendMessage(org.bukkit.ChatColor.RED + + "Usage: /craft fuel [amount]"); + } + } + public static void reloadPlugin(CommandSender sender) { if (!Fusion.getInstance().checkPermission(sender, "fusion.reload")) { return; @@ -460,7 +525,16 @@ private static void showIngredientUsage(Player player) { for (CraftingTable table : ProfessionsCfg.getMap().values()) { for (Recipe recipe : table.getRecipes().values()) { for (RecipeItem ingredient : recipe.getConditions().getRequiredItems()) { - if (CalculatedRecipe.isSimilar(ingredient.getItemStack(), item)) { + boolean matches; + // For DIV_ module items, use type-identity check instead of isSimilar() + // because getItemStack() generates a fresh random-stat item each call. + if (ingredient instanceof RecipeCustomItem rci + && rci.getItemType() instanceof DivinityModuleItemType divType) { + matches = divType.isInstance(item); + } else { + matches = CalculatedRecipe.isSimilar(ingredient.getItemStack(), item); + } + if (matches) { recipeUsage.put(recipe, ingredient); } } @@ -718,7 +792,14 @@ public static void forceShow(@NotNull CommandSender sender, String[] args) { for (CraftingTable table : ProfessionsCfg.getMap().values()) { for (Recipe recipe : table.getRecipes().values()) { for (RecipeItem ingredient : recipe.getConditions().getRequiredItems()) { - if (CalculatedRecipe.isSimilar(ingredient.getItemStack(), item)) { + boolean matches; + if (ingredient instanceof RecipeCustomItem rci + && rci.getItemType() instanceof DivinityModuleItemType divType) { + matches = divType.isInstance(item); + } else { + matches = CalculatedRecipe.isSimilar(ingredient.getItemStack(), item); + } + if (matches) { recipeUsage.put(recipe, ingredient); } } diff --git a/src/main/java/studio/magemonkey/fusion/commands/Commands.java b/src/main/java/studio/magemonkey/fusion/commands/Commands.java index 9af8c83..559cbdb 100644 --- a/src/main/java/studio/magemonkey/fusion/commands/Commands.java +++ b/src/main/java/studio/magemonkey/fusion/commands/Commands.java @@ -107,6 +107,10 @@ public boolean onCommand(@NotNull CommandSender sender, } return true; } + case "fuel" -> { + CommandMechanics.handleFuel(sender, args); + return true; + } default -> CodexEngine.get() .getMessageUtil() .sendMessage("fusion.help", sender, new MessageData("sender", sender), @@ -147,6 +151,7 @@ public List onTabComplete(@NotNull CommandSender sender, entries.add("show"); if (sender.hasPermission("fusion.admin") && "exp".startsWith(args[0])) entries.add("exp"); if (sender.hasPermission("fusion.admin") && "level".startsWith(args[0])) entries.add("level"); + if (sender.hasPermission("fusion.admin") && "fuel".startsWith(args[0])) entries.add("fuel"); // Force commands for administrators if (sender.hasPermission("fusion.admin.force")) { if ("forcejoin".startsWith(args[0])) entries.add("forcejoin"); @@ -195,6 +200,10 @@ public List onTabComplete(@NotNull CommandSender sender, if ("add".startsWith(args[1].toLowerCase())) entries.add("add"); if ("set".startsWith(args[1].toLowerCase())) entries.add("set"); if ("take".startsWith(args[1].toLowerCase())) entries.add("take"); + } else if (args[0].equalsIgnoreCase("fuel") && sender.hasPermission("fusion.admin")) { + for (String sub : List.of("add", "remove", "set", "check", "gui")) { + if (sub.startsWith(args[1].toLowerCase())) entries.add(sub); + } } // Tab completion for force commands - player names else if (sender.hasPermission("fusion.admin.force") && diff --git a/src/main/java/studio/magemonkey/fusion/commands/FusionEditorCommand.java b/src/main/java/studio/magemonkey/fusion/commands/FusionEditorCommand.java index e62d5f2..3100bd1 100644 --- a/src/main/java/studio/magemonkey/fusion/commands/FusionEditorCommand.java +++ b/src/main/java/studio/magemonkey/fusion/commands/FusionEditorCommand.java @@ -39,8 +39,10 @@ import studio.magemonkey.fusion.gui.editors.browse.BrowseEditor; import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.ChatUT; +import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.util.TabCacher; - +import studio.magemonkey.divinity.Divinity; +import studio.magemonkey.fusion.cfg.hooks.HookType; import java.util.*; public class FusionEditorCommand implements CommandExecutor, TabCompleter { @@ -168,6 +170,8 @@ public boolean onCommand(@NotNull CommandSender sender, case Profession_Recipe_Edit_ResultItem, Profession_Recipe_Add_Ingredients, Profession_Recipe_Edit_Ingredients -> updateRecipeItems(professionEditor, args, criteria); case Profession_Recipe_Edit_Permission -> updateRecipePermission(professionEditor, args); + case Profession_Recipe_Edit_Station -> updateRecipeStation(professionEditor, args); + case Profession_Recipe_Edit_FuelCost -> updateRecipeFuelCost(professionEditor, args); case Profession_Recipe_Add_Conditions -> addRecipeConditions(professionEditor, args); case RecipeIcon_Edit_Name -> updateRecipeIconName(professionEditor, args); @@ -194,6 +198,14 @@ public boolean onCommand(@NotNull CommandSender sender, default -> editor.open(player); } } + removeEditorCriteria(player.getUniqueId()); + // If editor still registered but no GUI is visible (e.g. command failed after suggestUsage), + // reopen root editor so player has a functional GUI and can't move items freely. + Editor remaining = EditorRegistry.getCurrentEditor(player); + if (remaining != null + && EditorRegistry.getEditorByInventory(player.getOpenInventory().getTopInventory()) == null) { + remaining.open(player); + } return true; } @@ -267,9 +279,26 @@ public List onTabComplete(@NotNull CommandSender sender, entries.add("5"); entries.add("32"); entries.add("64"); + } else if (args.length == 4) { + if (isDivItemWithLevel(args[1])) entries.addAll(TabCacher.getLevelHints(args[3])); + } else if (args.length == 5) { + if (args[1].toUpperCase().startsWith("DIV_ITEMGEN_")) { + String id = args[1].substring("DIV_ITEMGEN_".length()); + entries.addAll(TabCacher.getItemGenMaterialTypes(id, args[4])); + } } break; case Pattern_Edit_Pattern: + if (args.length == 1) { + entries.addAll(TabCacher.getTabs(TabCacher.GlobalUUID, "items", args[0])); + } else if (args.length == 2) { + entries.add(""); + entries.add("1"); + entries.add("5"); + entries.add("32"); + entries.add("64"); + } + break; case Profession_Recipe_Edit_ResultItem: case Profession_Recipe_Add_Ingredients: case Profession_Recipe_Edit_Ingredients: @@ -282,6 +311,13 @@ public List onTabComplete(@NotNull CommandSender sender, entries.add("5"); entries.add("32"); entries.add("64"); + } else if (args.length == 3) { + if (isDivItemWithLevel(args[0])) entries.addAll(TabCacher.getLevelHints(args[2])); + } else if (args.length == 4) { + if (args[0].toUpperCase().startsWith("DIV_ITEMGEN_")) { + String id = args[0].substring("DIV_ITEMGEN_".length()); + entries.addAll(TabCacher.getItemGenMaterialTypes(id, args[3])); + } } break; case Profession_Recipe_Edit_Name: @@ -312,6 +348,20 @@ public List onTabComplete(@NotNull CommandSender sender, case Browse_Profession_Add_Conditions: entries.addAll(TabCacher.getConditionsTabs(args)); break; + case Profession_Recipe_Edit_Station: + if (args.length == 1) { + entries.addAll(TabCacher.getTabs(TabCacher.GlobalUUID, "station", args[0])); + } + break; + case Profession_Recipe_Edit_FuelCost: + if (args.length == 1) { + entries.add(""); + entries.add("0"); + entries.add("1"); + entries.add("5"); + entries.add("10"); + } + break; case Pattern_Edit_Lore: case RecipeIcon_Edit_Lore: if (args.length == 1) { @@ -373,6 +423,16 @@ public List onTabComplete(@NotNull CommandSender sender, } break; case Pattern_Edit_Pattern: + if (args.length == 1) { + entries.addAll(TabCacher.getTabs(TabCacher.GlobalUUID, "items", args[0])); + } else if (args.length == 2) { + entries.add(""); + entries.add("1"); + entries.add("5"); + entries.add("32"); + entries.add("64"); + } + break; case Browse_Profession_Add_Ingredients: if (args.length == 1) { entries.addAll(TabCacher.getTabs(TabCacher.GlobalUUID, "items", args[0])); @@ -382,6 +442,13 @@ public List onTabComplete(@NotNull CommandSender sender, entries.add("5"); entries.add("32"); entries.add("64"); + } else if (args.length == 3) { + if (isDivItemWithLevel(args[0])) entries.addAll(TabCacher.getLevelHints(args[2])); + } else if (args.length == 4) { + if (args[0].toUpperCase().startsWith("DIV_ITEMGEN_")) { + String id = args[0].substring("DIV_ITEMGEN_".length()); + entries.addAll(TabCacher.getItemGenMaterialTypes(id, args[3])); + } } break; case Pattern_Add_Commands: @@ -413,15 +480,40 @@ public List onTabComplete(@NotNull CommandSender sender, return entries; } + /** Returns true if the item is a DIV_ module item that accepts a level argument. */ + private static boolean isDivItemWithLevel(String item) { + String u = item.toUpperCase(); + return RecipeItem.isDivModulePrefix(item) && !u.startsWith("DIV_CUSTOM_"); + } + + /** + * Joins args from nameIdx onward with ':' to form the config string. + * e.g. args=["DIV_ITEMGEN_sword","3","5","iron_sword"], nameIdx=0 → "DIV_ITEMGEN_sword:3:5:iron_sword" + */ + private static String buildItemConfigString(String[] args, int nameIdx) { + if (nameIdx >= args.length) return ""; + StringBuilder sb = new StringBuilder(args[nameIdx]); + for (int i = nameIdx + 1; i < args.length; i++) { + sb.append(':').append(args[i]); + } + return sb.toString(); + } + private boolean isValidItem(String item) { + // Check DIV_ module prefixes (DIV_ITEMGEN_, DIV_GEM_, DIV_ESSENCE_, DIV_RUNE_) + if (RecipeItem.isDivModulePrefix(item)) { + return RecipeItem.isValidDivModuleItem(item); + } try { // If the material in uppercase is valid, return true Material.valueOf(item.toUpperCase()); return true; } catch (IllegalArgumentException e) { - // If this is a custom item from divinity without "DIVINITY_" prefix, return true try { - return CodexEngine.get().getItemManager().getItemType(item) != null; + // Extract item key using DIVINITY_ pattern (same logic as RecipeItem.fromConfig) + java.util.regex.Matcher divMatcher = RecipeItem.divinityPattern.matcher(item); + String itemKey = divMatcher.find() ? divMatcher.group(0) : item.split(":")[0]; + return CodexEngine.get().getItemManager().getItemType(itemKey) != null; } catch (CodexItemException ignored) { return false; } @@ -447,6 +539,13 @@ public static void suggestUsage(Player player, EditorCriteria criteria, String s CodexEngine.get() .getMessageUtil() .sendMessage("editor.editorUsage", player, new MessageData("syntax", " ")); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", + player, + new MessageData("syntax", + "§7ITEMGEN: DIV_ITEMGEN_[_NOENCH]: | GEM/ESS/RUNE: DIV_GEM_[:]:")); + sendDivItemList(player); break; case Profession_Category_Add: case Profession_Category_Edit: @@ -482,6 +581,13 @@ public static void suggestUsage(Player player, EditorCriteria criteria, String s CodexEngine.get() .getMessageUtil() .sendMessage("editor.editorUsage", player, new MessageData("syntax", " ")); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", + player, + new MessageData("syntax", + "§7ITEMGEN: DIV_ITEMGEN_[_NOENCH]: | GEM/ESS/RUNE: DIV_GEM_[:]:")); + sendDivItemList(player); break; case Pattern_Edit_Lore: case RecipeIcon_Edit_Lore: @@ -495,11 +601,23 @@ public static void suggestUsage(Player player, EditorCriteria criteria, String s .sendMessage("editor.editorUsage", player, new MessageData("syntax", " ")); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", + player, + new MessageData("syntax", + "§7Divinity result: DIV_ITEMGEN_[~level:N][~material:MAT][_NOENCH] | DIV_GEM_[:]")); break; case Profession_Recipe_Edit_ResultItem: CodexEngine.get() .getMessageUtil() .sendMessage("editor.editorUsage", player, new MessageData("syntax", " ")); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", + player, + new MessageData("syntax", + "§7Divinity result: DIV_ITEMGEN_[~level:N][~material:MAT][_NOENCH] | DIV_GEM_[:]")); break; case Profession_Recipe_Edit_Name: CodexEngine.get() @@ -528,8 +646,23 @@ public static void suggestUsage(Player player, EditorCriteria criteria, String s .getMessageUtil() .sendMessage("editor.editorUsage", player, new MessageData("syntax", "")); break; + case Profession_Recipe_Edit_Station: + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", player, new MessageData("syntax", "")); + break; + case Profession_Recipe_Edit_FuelCost: + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.editorUsage", player, new MessageData("syntax", "")); + break; } sendSuggestMessage(player, suggestCommand); + Editor currentEditor = EditorRegistry.getCurrentEditor(player); + if (currentEditor != null) currentEditor.suppressNextClose(); + // Suppress the editor whose inventory page is actually open (e.g. RecipeEditor nested page) + Editor openEditor = EditorRegistry.getEditorByInventory(player.getOpenInventory().getTopInventory()); + if (openEditor != null && openEditor != currentEditor) openEditor.suppressNextClose(); player.closeInventory(); } @@ -621,6 +754,9 @@ private void updateCategory(ProfessionEditor professionEditor, String[] args, Ed player, new MessageData("category", categoryName), new MessageData("profession", professionEditor.getTable().getName())); + professionEditor.getCategoryEditor().reload(true); + professionEditor.autoSave(); + return; } else { if (!professionEditor.getTable().getCategories().containsKey(categoryName)) { CodexEngine.get() @@ -650,6 +786,7 @@ private void updateCategory(ProfessionEditor professionEditor, String[] args, Ed new MessageData("profession", professionEditor.getTable().getName())); } professionEditor.getCategoryEditor().reload(true); + professionEditor.autoSave(); } /* Patterns */ @@ -1089,8 +1226,9 @@ private void addRecipeItem(ProfessionEditor professionEditor, String[] args) { return; } try { - String itemName = args[0]; - int amount = Integer.parseInt(args[1]); + String itemName = args[0]; + int amount = Integer.parseInt(args[1]); + String configStr = buildItemConfigString(args, 0); if (!isValidItem(itemName)) { CodexEngine.get() .getMessageUtil() @@ -1102,7 +1240,7 @@ private void addRecipeItem(ProfessionEditor professionEditor, String[] args) { .getRecipe() .getResults() .getItemNames() - .add(itemName + ":" + amount); + .add(configStr); professionEditor.getRecipeEditor().getRecipeItemEditor().reload(true); } catch (NumberFormatException e) { e.printStackTrace(); @@ -1112,6 +1250,54 @@ private void addRecipeItem(ProfessionEditor professionEditor, String[] args) { } } + private static void closeAllEditors(Player player) { + Editor openEditor = EditorRegistry.getEditorByInventory( + player.getOpenInventory().getTopInventory()); + // Fix E: if player typed command while chat was open (no GUI visible), fall back to root editor + if (openEditor == null) { + openEditor = EditorRegistry.getCurrentEditor(player); + } + if (openEditor != null) { + Editor current = openEditor; + while (current != null) { + current.suppressNextClose(); + current.dispose(); + current = current.getParentEditor(); + } + } + EditorRegistry.removeCurrentEditor(player); + player.closeInventory(); + } + + private static void sendDivItemList(Player player) { + if (!Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) return; + List itemgenIds = Divinity.getInstance().getModuleCache().getTierManager().getItemIds(); + List gemIds = Divinity.getInstance().getModuleCache().getGemManager().getItemIds(); + List essIds = Divinity.getInstance().getModuleCache().getEssenceManager().getItemIds(); + List runeIds = Divinity.getInstance().getModuleCache().getRuneManager().getItemIds(); + String random = studio.magemonkey.divinity.modules.api.QModuleDrop.RANDOM_ID; + if (!itemgenIds.isEmpty()) { + List ids = itemgenIds.stream().filter(id -> !id.equals(random)).sorted().toList(); + CodexEngine.get().getMessageUtil().sendMessage("editor.editorUsage", player, + new MessageData("syntax", "§bITEMGEN: §7" + String.join(", ", ids))); + } + if (!gemIds.isEmpty()) { + List ids = gemIds.stream().filter(id -> !id.equals(random)).sorted().toList(); + CodexEngine.get().getMessageUtil().sendMessage("editor.editorUsage", player, + new MessageData("syntax", "§bGEM: §7" + String.join(", ", ids))); + } + if (!essIds.isEmpty()) { + List ids = essIds.stream().filter(id -> !id.equals(random)).sorted().toList(); + CodexEngine.get().getMessageUtil().sendMessage("editor.editorUsage", player, + new MessageData("syntax", "§bESSENCE: §7" + String.join(", ", ids))); + } + if (!runeIds.isEmpty()) { + List ids = runeIds.stream().filter(id -> !id.equals(random)).sorted().toList(); + CodexEngine.get().getMessageUtil().sendMessage("editor.editorUsage", player, + new MessageData("syntax", "§bRUNE: §7" + String.join(", ", ids))); + } + } + private void addNewRecipe(ProfessionEditor professionEditor, String[] args) { Player player = professionEditor.getPlayer(); if (args.length < 3) { @@ -1126,6 +1312,7 @@ private void addNewRecipe(ProfessionEditor professionEditor, String[] args) { String recipeName = args[0]; String itemName = args[1]; int amount = Integer.parseInt(args[2]); + String configStr = buildItemConfigString(args, 1); // item + amount + optional level/type for (Recipe recipe : professionEditor.getTable().getRecipes().values()) { if (recipe.getName().equalsIgnoreCase(recipeName)) { CodexEngine.get() @@ -1163,7 +1350,7 @@ private void addNewRecipe(ProfessionEditor professionEditor, String[] args) { new ArrayList())); recipeSettings.put("conditions", Map.of("professionLevel", 0, "mastery", false)); recipeSettings.put("costs", Map.of("items", List.of("STONE:3"), "money", 0.0, "exp", 0)); - recipeSettings.put("settings", Map.of("icon", Map.of("item", itemName + ":" + amount))); + recipeSettings.put("settings", Map.of("icon", Map.of("item", configStr))); professionEditor.getTable() .getRecipes() @@ -1175,6 +1362,7 @@ private void addNewRecipe(ProfessionEditor professionEditor, String[] args) { new MessageData("recipe", recipeName), new MessageData("result", itemName)); professionEditor.getRecipeEditor().reload(true); + professionEditor.autoSave(); } catch (NumberFormatException e) { e.printStackTrace(); CodexEngine.get() @@ -1185,15 +1373,16 @@ private void addNewRecipe(ProfessionEditor professionEditor, String[] args) { private void updateRecipeItems(ProfessionEditor professionEditor, String[] args, EditorCriteria criteria) { Player player = professionEditor.getPlayer(); - if (args.length != 2) { + if (args.length < 2) { CodexEngine.get() .getMessageUtil() .sendMessage("editor.invalidSyntax", player, new MessageData("syntax", " ")); return; } try { - String itemName = args[0]; - int amount = Integer.parseInt(args[1]); + String itemName = args[0]; + int amount = Integer.parseInt(args[1]); + String configStr = buildItemConfigString(args, 0); if (!isValidItem(itemName)) { CodexEngine.get() .getMessageUtil() @@ -1208,12 +1397,12 @@ private void updateRecipeItems(ProfessionEditor professionEditor, String[] args, .getRecipeItemEditor() .getRecipe() .getSettings() - .setRecipeItem(RecipeItem.fromConfig(itemName + ":" + amount)); + .setRecipeItem(RecipeItem.fromConfig(configStr)); professionEditor.getRecipeEditor() .getRecipeItemEditor() .getRecipe() .getSettings() - .setIconNamespace(itemName + ":" + amount); + .setIconNamespace(configStr); CodexEngine.get() .getMessageUtil() .sendMessage("editor.resultEdited", @@ -1239,13 +1428,13 @@ private void updateRecipeItems(ProfessionEditor professionEditor, String[] args, .getRecipe() .getConditions() .getRequiredItems() - .set(i, RecipeItem.fromConfig(itemName + ":" + amount)); + .set(i, RecipeItem.fromConfig(configStr)); professionEditor.getRecipeEditor() .getRecipeItemEditor() .getRecipe() .getConditions() .getRequiredItemNames() - .set(i, itemName + ":" + amount); + .set(i, configStr); found = true; } i++; @@ -1256,13 +1445,13 @@ private void updateRecipeItems(ProfessionEditor professionEditor, String[] args, .getRecipe() .getConditions() .getRequiredItems() - .add(RecipeItem.fromConfig(itemName + ":" + amount)); + .add(RecipeItem.fromConfig(configStr)); professionEditor.getRecipeEditor() .getRecipeItemEditor() .getRecipe() .getConditions() .getRequiredItemNames() - .add(itemName + ":" + amount); + .add(configStr); } break; case Profession_Recipe_Edit_Ingredients: @@ -1277,13 +1466,13 @@ private void updateRecipeItems(ProfessionEditor professionEditor, String[] args, .getRecipe() .getConditions() .getRequiredItems() - .add(RecipeItem.fromConfig(itemName + ":" + amount)); + .add(RecipeItem.fromConfig(configStr)); professionEditor.getRecipeEditor() .getRecipeItemEditor() .getRecipe() .getConditions() .getRequiredItemNames() - .add(itemName + ":" + amount); + .add(configStr); break; } professionEditor.getRecipeEditor().getRecipeItemEditor().reload(true); @@ -1311,6 +1500,46 @@ private void updateRecipePermission(ProfessionEditor professionEditor, String[] professionEditor.getRecipeEditor().getRecipeItemEditor().reload(true); } + private void updateRecipeStation(ProfessionEditor professionEditor, String[] args) { + Player player = professionEditor.getPlayer(); + if (args.length != 1) { + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.invalidSyntax", player, new MessageData("syntax", "")); + return; + } + String station = args[0]; + professionEditor.getRecipeEditor().getRecipeItemEditor().getRecipe().getConditions().setStation(station); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.recipeStationUpdated", player, new MessageData("station", station)); + professionEditor.getRecipeEditor().getRecipeItemEditor().reload(true); + } + + private void updateRecipeFuelCost(ProfessionEditor professionEditor, String[] args) { + Player player = professionEditor.getPlayer(); + if (args.length != 1) { + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.invalidSyntax", player, new MessageData("syntax", "")); + return; + } + try { + int fuelCost = Integer.parseInt(args[0]); + professionEditor.getRecipeEditor().getRecipeItemEditor().getRecipe().getConditions().setFuelCost(fuelCost); + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.recipeFuelCostUpdated", + player, + new MessageData("amount", String.valueOf(fuelCost))); + professionEditor.getRecipeEditor().getRecipeItemEditor().reload(true); + } catch (NumberFormatException e) { + CodexEngine.get() + .getMessageUtil() + .sendMessage("editor.invalidNumber", player, new MessageData("number", args[0])); + } + } + private void addRecipeConditions(ProfessionEditor professionEditor, String[] args) { Player player = professionEditor.getPlayer(); if (args.length != 3) { @@ -1559,15 +1788,16 @@ private void addNewProfession(BrowseEditor browseEditor, String[] args) { private void addBrowseIngredient(BrowseEditor browseEditor, String[] args) { Player player = browseEditor.getPlayer(); - if (args.length != 2) { + if (args.length < 2) { CodexEngine.get() .getMessageUtil() .sendMessage("editor.invalidSyntax", player, new MessageData("syntax", " ")); return; } try { - String itemName = args[0]; - int amount = Integer.parseInt(args[1]); + String itemName = args[0]; + int amount = Integer.parseInt(args[1]); + String configStr = buildItemConfigString(args, 0); if (!isValidItem(itemName)) { CodexEngine.get() .getMessageUtil() @@ -1586,12 +1816,12 @@ private void addBrowseIngredient(BrowseEditor browseEditor, String[] args) { .getBrowseProfessionEditor() .getConditions() .getRequiredItems() - .set(i, RecipeItem.fromConfig(itemName + ":" + amount)); + .set(i, RecipeItem.fromConfig(configStr)); browseEditor.getBrowseProfessionsEditor() .getBrowseProfessionEditor() .getConditions() .getRequiredItemNames() - .set(i, itemName + ":" + amount); + .set(i, configStr); found = true; } i++; @@ -1601,12 +1831,12 @@ private void addBrowseIngredient(BrowseEditor browseEditor, String[] args) { .getBrowseProfessionEditor() .getConditions() .getRequiredItems() - .add(RecipeItem.fromConfig(itemName + ":" + amount)); + .add(RecipeItem.fromConfig(configStr)); browseEditor.getBrowseProfessionsEditor() .getBrowseProfessionEditor() .getConditions() .getRequiredItemNames() - .add(itemName + ":" + amount); + .add(configStr); browseEditor.getBrowseProfessionsEditor().getBrowseProfessionEditor().reload(true); } } catch (NumberFormatException 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..eae07d9 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/FusionPlayer.java @@ -367,26 +367,26 @@ public void save(boolean clearCaches) { 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()) { - 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. + Runnable saveTask = () -> { try { - Thread.sleep(250); - } catch (InterruptedException e) { - throw new RuntimeException(e); + SQLManager.players().setAutoCrafting(uuid, autoCrafting); + for (Profession profession : professions.values()) { + SQLManager.professions().setProfession(uuid, profession); + } + for (CraftingQueue queue : queuesToSave.values()) { + SQLManager.queues().saveCraftingQueue(queue); + } + SQLManager.recipeLimits().saveRecipeLimits(uuid, recipeLimitsToSave); + } finally { + SQLManager.players().setLocked(uuid, false); + this.locked = false; } - */ - SQLManager.players().setLocked(uuid, false); - this.locked = false; - }); + }; + // Fix D: save synchronously during shutdown so the scheduler can't cancel the task + if (!Fusion.getInstance().isEnabled()) { + saveTask.run(); + } else { + Bukkit.getScheduler().runTaskAsynchronously(Fusion.getInstance(), saveTask); + } } } 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..c8459e2 100644 --- a/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java +++ b/src/main/java/studio/magemonkey/fusion/data/player/PlayerLoader.java @@ -5,21 +5,18 @@ import studio.magemonkey.fusion.cfg.sql.SQLManager; import java.util.Map; -import java.util.TreeMap; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; public class PlayerLoader { - private static final Map cachedPlayers = new TreeMap<>(); + private static final Map cachedPlayers = new ConcurrentHashMap<>(); public static FusionPlayer getPlayer(UUID uuid) { // Always return a FusionPlayer instance. Previously this returned null when the player was marked as // locked in the database (during async save), which caused NullPointerExceptions at many call sites. // Returning a FusionPlayer ensures call sites remain stable; the locking is handled at the persistence layer. - if (!cachedPlayers.containsKey(uuid)) { - cachedPlayers.put(uuid, new FusionPlayer(uuid)); - } - return cachedPlayers.get(uuid); + return cachedPlayers.computeIfAbsent(uuid, FusionPlayer::new); } public static FusionPlayer getPlayer(Player player) { @@ -72,10 +69,9 @@ public static void loadPlayer(Player player) { } public static void unloadPlayer(Player player) { - if (cachedPlayers.containsKey(player.getUniqueId())) { - FusionPlayer fusionPlayer = cachedPlayers.get(player.getUniqueId()); + FusionPlayer fusionPlayer = cachedPlayers.remove(player.getUniqueId()); + if (fusionPlayer != null) { fusionPlayer.save(true); - cachedPlayers.remove(player.getUniqueId()); } } diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionConditions.java b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionConditions.java index 409ce59..1e03a0a 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionConditions.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionConditions.java @@ -57,6 +57,10 @@ public class ProfessionConditions implements ConfigurationSerializable { private boolean isMastery; @Setter private String permission; + @Setter + private String station; + @Setter + private int fuelCost; private final Map professionConditions = new LinkedHashMap<>(); private final Map fabledClassConditions = new LinkedHashMap<>(); @@ -113,11 +117,15 @@ public ProfessionConditions(String profession, ConfigurationSection config) { .stream() .map(RecipeItem::fromConfig) .collect(Collectors.toCollection(LinkedList::new)); - this.requiredItemNames = (List) config.getList("costs.items"); + @SuppressWarnings("unchecked") + List rawItems = (List) config.getList("costs.items"); + this.requiredItemNames = rawItems != null ? new LinkedList<>(rawItems) : new LinkedList<>(); this.professionLevel = config.getInt("conditions.professionLevel", 0); this.isMastery = config.getBoolean("conditions.mastery", false); this.permission = config.getString("conditions.permission"); + this.station = config.getString("conditions.station"); + this.fuelCost = config.getInt("conditions.fuel-cost", 0); if (config.isSet("conditions.professions")) { for (String key : Objects.requireNonNull(config.getConfigurationSection("conditions.professions")) @@ -192,7 +200,8 @@ public ProfessionConditions(String profession, DeserializationWorker dw) { .stream() .map(RecipeItem::fromConfig) .collect(Collectors.toCollection(LinkedList::new)); - this.requiredItemNames = (List) costsSection.get("items"); + List rawItems2 = (List) costsSection.get("items"); + this.requiredItemNames = rawItems2 != null ? new LinkedList<>(rawItems2) : new LinkedList<>(); } Map conditionsSection = dw.getSection("conditions"); @@ -202,6 +211,8 @@ public ProfessionConditions(String profession, DeserializationWorker dw) { this.professionLevel = (int) conditionsSection.getOrDefault("professionLevel", 0); this.isMastery = (boolean) conditionsSection.getOrDefault("mastery", false); this.permission = (String) conditionsSection.getOrDefault("permission", null); + this.station = (String) conditionsSection.getOrDefault("station", null); + this.fuelCost = (int) conditionsSection.getOrDefault("fuel-cost", 0); Map conditions = (Map) conditionsSection.get("professions"); if (conditions != null) { @@ -656,6 +667,8 @@ public List> getConditionLines(Player player) { conditionsMap.put("professionLevel", this.professionLevel); conditionsMap.put("mastery", this.isMastery); conditionsMap.put("permission", this.permission); + if (this.station != null) conditionsMap.put("station", this.station); + if (this.fuelCost > 0) conditionsMap.put("fuel-cost", this.fuelCost); if (!professionConditions.isEmpty()) conditionsMap.put("professions", this.professionConditions); if (!fabledClassConditions.isEmpty()) @@ -714,7 +727,7 @@ public static ProfessionConditions copy(ProfessionConditions conditions) { Map auraStatsConditions = new LinkedHashMap<>(conditions.getAuraStatsConditions()); - return new ProfessionConditions(conditions.getProfession(), + ProfessionConditions copy = new ProfessionConditions(conditions.getProfession(), conditions.getMoneyCost(), conditions.getExpCost(), new LinkedList<>(conditions.getRequiredItemNames() != null ? conditions.getRequiredItemNames() : new LinkedList<>()), @@ -729,5 +742,8 @@ public static ProfessionConditions copy(ProfessionConditions conditions) { auraManaAbilityConditions, auraSkillsConditions, auraStatsConditions); + copy.setStation(conditions.getStation()); + copy.setFuelCost(conditions.getFuelCost()); + return copy; } } diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionResults.java b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionResults.java index c71b128..68588f8 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionResults.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/ProfessionResults.java @@ -121,6 +121,6 @@ public static ProfessionResults copy(ProfessionResults results) { } public boolean hasCommandsOrItems() { - return professionExp > 0 || vanillaExp > 0 || !commands.isEmpty() || !itemNames.isEmpty(); + return !commands.isEmpty() || !itemNames.isEmpty(); } } diff --git a/src/main/java/studio/magemonkey/fusion/data/professions/pattern/Category.java b/src/main/java/studio/magemonkey/fusion/data/professions/pattern/Category.java index d9355c5..9078632 100644 --- a/src/main/java/studio/magemonkey/fusion/data/professions/pattern/Category.java +++ b/src/main/java/studio/magemonkey/fusion/data/professions/pattern/Category.java @@ -37,6 +37,9 @@ public class Category implements ConfigurationSerializable { @Getter @Setter private int order; + @Getter + @Setter + private int slot = -1; private boolean hasPrevious = true; @Getter @@ -87,6 +90,12 @@ public Category(Map map) { displayName = (String) displaySection.getOrDefault("name", null); displayLore = (List) displaySection.getOrDefault("lore", null); } + // Top-level lore overrides display.lore if present + List topLevelLore = (List) map.get("lore"); + if (topLevelLore != null) { + displayLore = topLevelLore; + } + slot = dw.getInt("slot", -1); pattern = dw.getSection("pattern") != null ? new InventoryPattern(dw.getSection("pattern")) : null; } @@ -97,6 +106,14 @@ public Category(Map map) { map.put("name", name); map.put("order", order); map.put("icon", iconName); + if (slot >= 0) + map.put("slot", slot); + if (displayName != null || displayLore != null) { + Map display = new HashMap<>(); + if (displayName != null) display.put("name", displayName); + if (displayLore != null) display.put("lore", displayLore); + map.put("display", display); + } if (pattern != null) map.put("pattern", pattern.serialize()); return map; @@ -135,8 +152,9 @@ public ItemStack getDisplayIcon() { ItemMeta meta = item.getItemMeta(); if (meta == null) return item; - if (displayName != null) { - String translated = ChatUT.hexString(displayName); + String nameToShow = displayName != null ? displayName : name; + if (nameToShow != null) { + String translated = ChatUT.hexString(nameToShow); meta.setDisplayName(translated); try { meta.setItemName(translated); 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..12abefe 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/CalculatedRecipe.java @@ -18,12 +18,13 @@ import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; import studio.magemonkey.fusion.util.ExperienceManager; import studio.magemonkey.fusion.util.InvalidPatternItemException; +import studio.magemonkey.fusion.util.StationChecker; import studio.magemonkey.fusion.util.Utils; import java.util.*; /** - * A “calculated” recipe icon + canCraft flag. We build lore lines (ingredients, money, xp, etc.) + * A "calculated" recipe icon + canCraft flag. We build lore lines (ingredients, money, xp, etc.) * once per relevant change, and store the final Icon + canCraft in a single object. */ @Getter @@ -72,7 +73,7 @@ public static CalculatedRecipe create(Recipe recipe, lore.append(" ").append('\n'); } - // 1) “Requirement” header + // 1) "Requirement" header String requirementLine = CraftingRequirementsCfg.getCraftingRequirementLine("recipes"); if (!requirementLine.isEmpty()) { lore.append(requirementLine).append('\n'); @@ -149,7 +150,6 @@ public static CalculatedRecipe create(Recipe recipe, recipe.getConditions().isMastery() ); } - // 7) Crafting limit String limitLine = null; if (recipe.getCraftingLimit() > 0) { @@ -181,7 +181,20 @@ public static CalculatedRecipe create(Recipe recipe, break; } } - + // ─── 8b) Station requirement (Divinity / vanilla) ─── + String stationLine = null; + String station = recipe.getStation(); + if (station != null && !station.isEmpty()) { + boolean hasStation = StationChecker.hasStation(player, station); + if (!hasStation) { + canCraft = false; + } + stationLine = CraftingRequirementsCfg.getStationLine( + "recipes", + hasStation, + station + ); + } // // ─── 9) Ingredient check using invCounts ─── // @@ -191,14 +204,25 @@ public static CalculatedRecipe create(Recipe recipe, for (Iterator it = localPattern.iterator(); it.hasNext(); ) { RecipeItem required = it.next(); - - // We only compare a single “unit” for matching: - ItemStack single = required.getItemStack().clone(); - single.setAmount(1); - - IngredientFingerprint reqKey = IngredientFingerprint.of(single); - int have = invCounts.getOrDefault(reqKey, 0); - int need = required.getAmount(); + int need = required.getAmount(); + int have; + + // Build the required fingerprint once — forRequired() avoids generating a + // random-level item for Divinity ITEMGEN/GEM/etc. ingredients with no level + // constraint (level=-1 acts as wildcard in equals/hashCode). + IngredientFingerprint reqKey = (required instanceof RecipeTagItem) + ? null + : IngredientFingerprint.forRequired(required); + + if (required instanceof RecipeTagItem tagItem) { + have = 0; + for (Map.Entry e : invCounts.entrySet()) { + if (tagItem.getTag().isTagged(e.getKey().getType())) have += e.getValue(); + } + } else { + // sumMatchingEntries handles wildcard level (-1) by iterating all matching entries + have = reqKey.sumMatchingEntries(invCounts); + } if (have < need) { canCraft = false; @@ -209,7 +233,23 @@ public static CalculatedRecipe create(Recipe recipe, } // Subtract used quantity so overlapping items are handled correctly - invCounts.put(reqKey, have - need); + if (required instanceof RecipeTagItem tagItem) { + List tagKeys = new ArrayList<>(); + for (IngredientFingerprint k : invCounts.keySet()) { + if (tagItem.getTag().isTagged(k.getType())) tagKeys.add(k); + } + int remaining = need; + for (IngredientFingerprint k : tagKeys) { + if (remaining == 0) break; + int cur = invCounts.getOrDefault(k, 0); + int drain = Math.min(cur, remaining); + invCounts.put(k, cur - drain); + remaining -= drain; + } + } else { + // drainFromMap handles wildcard level (-1) by draining across all matching entries + reqKey.drainFromMap(invCounts, need); + } lore.append( CraftingRequirementsCfg.getIngredientLine("recipes", required, have, need) @@ -224,6 +264,7 @@ public static CalculatedRecipe create(Recipe recipe, if (expLine != null) lore.append(expLine).append('\n'); if (masteryLine != null) lore.append(masteryLine).append('\n'); if (limitLine != null) lore.append(limitLine).append('\n'); + if (stationLine != null) lore.append(stationLine).append('\n'); if (!conditionLines.isEmpty()) { String conditionLine = CraftingRequirementsCfg.getCraftingConditionLine("recipes"); if (!conditionLine.isEmpty()) { @@ -239,8 +280,11 @@ public static CalculatedRecipe create(Recipe recipe, // Build final icon + lore ItemStack icon = iconResult.clone(); ItemMeta im = icon.getItemMeta(); - im.setLore(Arrays.asList(StringUtils.split(lore.toString(), '\n'))); - icon.setItemMeta(im); + if (im == null) im = Bukkit.getItemFactory().getItemMeta(icon.getType()); + if (im != null) { + im.setLore(Arrays.asList(StringUtils.split(lore.toString(), '\n'))); + icon.setItemMeta(im); + } return new CalculatedRecipe(recipe, icon, canCraft); } catch (Exception e) { @@ -270,7 +314,7 @@ public int hashCode() { } /** - * Unchanged “isSimilar” from before—compares two ItemStacks in a relaxed manner. + * Unchanged "isSimilar" from before—compares two ItemStacks in a relaxed manner. */ public static boolean isSimilar(ItemStack is1, ItemStack is2) { if (is1.getType() != is2.getType()) return false; diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/CraftingTable.java b/src/main/java/studio/magemonkey/fusion/data/recipes/CraftingTable.java index 42cc65d..10b306c 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/CraftingTable.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/CraftingTable.java @@ -129,6 +129,11 @@ public CraftingTable(Map map) throws MissingProviderException, M .forEach(c -> { if (c.getPattern() == null) c.setPattern(recipePattern); + if (categories.containsKey(c.getName())) { + Fusion.getInstance().getLogger().warning( + "[" + this.name + "] Duplicate category name '" + c.getName() + + "' — second definition overwrites the first."); + } categories.put(c.getName(), c); }); @@ -156,6 +161,10 @@ public CraftingTable(Map map) throws MissingProviderException, M Category category = categories.get(categoryStr); if (category == null) { + Fusion.getInstance().getLogger().warning( + "[" + this.name + "] Recipe '" + recipe.getName() + + "' references unknown category '" + categoryStr + + "' — recipe loaded but will not appear in any category tab."); continue; } @@ -363,6 +372,8 @@ public void moveEntry(Recipe recipe, int offset) { .append("masteryFee", this.masteryFee) .append("maxLevel", this.maxLevel) .append("useCategories", useCategories) + .append("categories", + this.categories.values().stream().map(Category::serialize).collect(Collectors.toList())) .append("recipes", this.recipes.values().stream().map(Recipe::serialize).collect(Collectors.toList())) .build(); } @@ -392,6 +403,7 @@ public void save(Runnable runnable) { config.set("masteryFee", map.get("masteryFee")); config.set("maxLevel", map.get("maxLevel")); config.set("useCategories", map.get("useCategories")); + config.set("categories", map.get("categories")); config.set("recipes", map.get("recipes")); try { config.save(file); @@ -426,6 +438,43 @@ public static CraftingTable copy(CraftingTable source) { categories); } + // Copy for editor: deduplicates ItemGen pseudo-recipes (name::material) in a single pass, + // avoiding O(N*M) Recipe.copy() calls that caused multi-second lag with large professions. + public static CraftingTable copyForEditor(CraftingTable source) { + Map recipes = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + for (Recipe recipe : source.getRecipes().values()) { + String name = recipe.getName(); + if (name.contains("::")) { + String baseName = name.split("::")[0]; + if (seen.add(baseName)) { + Recipe copy = Recipe.copy(recipe); + copy.setName(baseName); + recipes.put(baseName, copy); + } + } else { + if (seen.add(name)) { + recipes.put(name, Recipe.copy(recipe)); + } + } + } + Map categories = new LinkedHashMap<>(); + for (Category category : source.getCategories().values()) { + categories.put(category.getName(), Category.copy(category)); + } + return new CraftingTable(source.getName(), + source.getInventoryName(), + source.getIconItem(), + InventoryPattern.copy(source.getRecipePattern()), + InventoryPattern.copy(source.getCatPattern()), + source.getUseCategories(), + source.getFillItem(), + source.getMasteryUnlock(), + source.getMasteryFee(), + recipes, + categories); + } + // A method to cleanup 'pseudo' recipes created from the ItemGenerator Module of Divinity. // If not cleaned, the gui results in a OOM-Exception. public void cleanUpRecipesForEditor() { diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/Recipe.java b/src/main/java/studio/magemonkey/fusion/data/recipes/Recipe.java index af9f234..8619ade 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/Recipe.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/Recipe.java @@ -32,7 +32,8 @@ public class Recipe implements ConfigurationSerializable { @Setter private String name; - + @Setter + private String station; @Setter private int craftingTime; @Setter @@ -55,6 +56,7 @@ public Recipe(CraftingTable table, Map map) { this.table = table; DeserializationWorker dw = DeserializationWorker.start(map); this.name = dw.getString("name"); + this.station = dw.getString("station"); this.category = dw.getString("category"); this.craftingTime = dw.getInt("craftingTime"); @@ -70,6 +72,7 @@ public Recipe(CraftingTable table, Map map, DivinityRecipeMeta m this.table = table; DeserializationWorker dw = DeserializationWorker.start(map); this.name = dw.getString("name"); + this.station = dw.getString("station"); this.category = dw.getString("category"); this.craftingTime = dw.getInt("craftingTime"); @@ -88,6 +91,7 @@ public Recipe(CraftingTable table, Map map, DivinityRecipeMeta m public Recipe(CraftingTable table, String name, + String station, String category, int craftingTime, int craftingLimit, @@ -98,6 +102,7 @@ public Recipe(CraftingTable table, DivinityRecipeMeta meta) { this.table = table; this.name = name; + this.station = station; this.category = category; this.craftingTime = craftingTime; this.craftingLimit = craftingLimit; @@ -218,6 +223,7 @@ public String toString() { .append("craftingTime", this.craftingTime) .append("craftingLimit", this.craftingLimit) .append("craftingLimitCooldown", this.craftingLimitCooldown) + .append("station", this.station) .toString(); } @@ -228,7 +234,10 @@ public String toString() { .append("craftingTime", this.craftingTime) .append("craftingLimit", this.craftingLimit) .append("craftingLimitCooldown", this.craftingLimitCooldown); - + + if (station != null) { + builder.append("station", this.station); // <-- DODAJ + } if (category != null) { builder.append("category", this.category); } @@ -248,6 +257,7 @@ public String toString() { public static Recipe copy(Recipe recipe) { return new Recipe(recipe.getTable(), recipe.getName(), + recipe.getStation(), recipe.getCategory(), recipe.getCraftingTime(), recipe.getCraftingLimit(), diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeCustomItem.java b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeCustomItem.java index 7950017..47cdac6 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeCustomItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeCustomItem.java @@ -3,6 +3,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; +import org.jetbrains.annotations.Nullable; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; @@ -27,12 +28,36 @@ public class RecipeCustomItem implements RecipeItem { private final int amount; private final boolean simple; + /** Original config key for DIV_ module items (e.g. "DIV_ITEMGEN_sword~level:5"). + * Excluded from equals/hashCode to preserve logical item identity. */ + @EqualsAndHashCode.Exclude + @ToString.Exclude + private final String originalKey; + + /** Cached result of getItemStack() — avoids re-generating DIV_ITEMGEN random stats on every render. + * Naturally invalidated when config reloads (new RecipeCustomItem instances are created). */ + @EqualsAndHashCode.Exclude + @ToString.Exclude + private transient ItemStack displayCache; + public RecipeCustomItem(@NotNull ItemType item, int amount, boolean simple) { + this(item, amount, simple, null); + } + + public RecipeCustomItem(@NotNull ItemType item, int amount, boolean simple, @Nullable String originalKey) { this.item = item; this.builder = null; this.meta = null; this.amount = amount; this.simple = simple; + this.originalKey = originalKey; + } + + /** Returns the underlying {@link ItemType}, or {@code null} if this item was created from an + * {@link ItemBuilder} or a {@link DivinityRecipeMeta}. */ + @Nullable + public ItemType getItemType() { + return this.item; } public RecipeCustomItem(@NotNull ItemBuilder item, int amount, boolean simple) { @@ -41,6 +66,7 @@ public RecipeCustomItem(@NotNull ItemBuilder item, int amount, boolean simple) { this.meta = null; this.amount = amount; this.simple = simple; + this.originalKey = null; } public RecipeCustomItem(@NotNull DivinityRecipeMeta meta) { @@ -49,25 +75,32 @@ public RecipeCustomItem(@NotNull DivinityRecipeMeta meta) { this.meta = meta; this.amount = meta.getAmount(); this.simple = false; + this.originalKey = null; } @Override public ItemStack getItemStack() { - ItemStack clone = null; + if (displayCache != null) return displayCache.clone(); + ItemStack built; if (this.item != null) { - clone = this.item.create(); + built = this.item.create(); } else if (this.builder != null) { - clone = this.builder.build(); + built = this.builder.build(); } else { - clone = this.meta.generateItem(); + built = this.meta.generateItem(); } - - clone.setAmount(clone.getAmount() * this.amount); - return clone; + built.setAmount(built.getAmount() * this.amount); + displayCache = built.clone(); + return built; } @Override public Object toConfig() { + // For DIV_ module items the full config string (including amount) is stored in originalKey. + if (this.originalKey != null) { + return this.originalKey; + } + ItemStack it = null; if (this.item != null) { it = this.item.create(); diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeItem.java b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeItem.java index 981dbf5..86363d5 100644 --- a/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeItem.java +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeItem.java @@ -1,7 +1,10 @@ package studio.magemonkey.fusion.data.recipes; import org.apache.commons.lang3.StringUtils; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -11,7 +14,10 @@ import studio.magemonkey.codex.api.items.exception.MissingProviderException; import studio.magemonkey.codex.items.CodexItemManager; import studio.magemonkey.codex.legacy.item.ItemBuilder; +import studio.magemonkey.divinity.Divinity; import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.cfg.hooks.HookType; +import studio.magemonkey.fusion.cfg.hooks.divinity.DivinityModuleItemType; import studio.magemonkey.fusion.cfg.hooks.divinity.DivinityRecipeMeta; import java.util.Map; @@ -20,8 +26,10 @@ public interface RecipeItem { String CUSTOM_PREFIX = "@ "; + // The negative lookahead (?!\\d+(?:[^\\w]|$)) in the namespace group prevents it from + // consuming "itemname:" when the following part is a pure integer (the :amount suffix). Pattern divinityPattern = - Pattern.compile("DIVINITY_([\\w-]+:)?([^:~]+)\\b((~level:(\\d+))|(~material:([\\w-]+)\\b)){0,2}"); + Pattern.compile("DIVINITY_([\\w-]+:(?!\\d+(?:[^\\w]|$)))?([^:~]+)\\b((~level:(\\d+))|(~material:([\\w-]+)\\b)){0,2}"); int getAmount(); @@ -29,6 +37,39 @@ public interface RecipeItem { Object toConfig(); + /** Returns true if the item string starts with a DIV_ module prefix (case-insensitive). */ + static boolean isDivModulePrefix(String item) { + String u = item.toUpperCase(); + return u.startsWith("DIV_ITEMGEN_") || u.startsWith("DIV_GEM_") + || u.startsWith("DIV_ESSENCE_") || u.startsWith("DIV_RUNE_") + || u.startsWith("DIV_EXTRACTOR_") || u.startsWith("DIV_ARROW_") + || u.startsWith("DIV_CONSUMABLE_") || u.startsWith("DIV_CUSTOM_") + || u.startsWith("DIV_FORTIFY_") || u.startsWith("DIV_IDENTIFY_") + || u.startsWith("DIV_DUST_") || u.startsWith("DIV_REPAIR_") + || u.startsWith("DIV_DISMANTLE_") || u.startsWith("DIV_REFINE_"); + } + + /** + * Validates a DIV_ module item by name (no :amount suffix expected). + * Strips optional ~level:N and params before lookup. + */ + static boolean isValidDivModuleItem(String item) { + if (!isDivModulePrefix(item)) return false; + if (!Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) return false; + try { + String base = item.replaceAll("~level:\\d+", "").replaceAll("~material:\\w+", "").trim(); + String upper = base.toUpperCase(); + // Strip any :params suffix (amount, level, type) + int modEnd = upper.indexOf('_', 4); + int idStart = (modEnd >= 0) ? modEnd + 1 : base.length(); + int colon = base.indexOf(':', idStart); + if (colon >= 0) base = base.substring(0, colon); + return parseDivModuleItem(base, base, -1, 1, null) != null; + } catch (Exception ignored) { + return false; + } + } + static RecipeItem fromConfig(Object obj) { if (obj instanceof Map) { //noinspection unchecked @@ -39,6 +80,100 @@ static RecipeItem fromConfig(Object obj) { RecipeItem result = null; if (obj instanceof String itemString) { + // Handle TAG_: — matches any material in the given Bukkit Tag + if (itemString.toUpperCase().startsWith("TAG_")) { + String[] parts = itemString.split(":", 2); + String tagName = parts[0].substring(4).toLowerCase(); + int amt = 1; + if (parts.length > 1) { + try { amt = Integer.parseInt(parts[1]); } catch (NumberFormatException ignored) {} + } + Tag tag = Bukkit.getTag(Tag.REGISTRY_BLOCKS, NamespacedKey.minecraft(tagName), Material.class); + if (tag == null) { + tag = Bukkit.getTag(Tag.REGISTRY_ITEMS, NamespacedKey.minecraft(tagName), Material.class); + } + if (tag == null) { + throw new RuntimeException("Unknown material tag: \"" + tagName + + "\". Check https://minecraft.wiki/w/Tag for valid tag names."); + } + return new RecipeTagItem(tag, tagName, amt); + } + + + // Handle DIV_ module prefixes (DIV_ITEMGEN_, DIV_GEM_, DIV_CUSTOM_, etc.) + if (isDivModulePrefix(itemString) + && Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) { + int amt = 1; + int lvl = -1; + ItemType materialType = null; + String base = itemString; + String upper = base.toUpperCase(); + + // --- Backward compat: ~level:LVL:AMT format (e.g. DIV_ITEMGEN_name~level:5:3) --- + int levelIdx = base.indexOf("~level:"); + if (levelIdx >= 0) { + String afterLevel = base.substring(levelIdx + 7); + int nextColon = afterLevel.indexOf(':'); + if (nextColon >= 0) { + try { lvl = Integer.parseInt(afterLevel.substring(0, nextColon)); } catch (NumberFormatException ignored) {} + int lastColon = base.lastIndexOf(':'); + try { amt = Integer.parseInt(base.substring(lastColon + 1)); } catch (NumberFormatException ignored) {} + } else { + try { lvl = Integer.parseInt(afterLevel); } catch (NumberFormatException ignored) {} + } + base = base.substring(0, levelIdx); + } else { + // --- New format: DIV_MODULE_ID:AMT[:LVL[:TYPE]] --- + int modEnd = upper.indexOf('_', 4); // '_' between module type and ID + int idStart = (modEnd >= 0) ? modEnd + 1 : base.length(); + int firstColon = base.indexOf(':', idStart); + if (firstColon >= 0) { + String id = base.substring(idStart, firstColon); + String params = base.substring(firstColon + 1); + + // Backward compat: AMT;MINLEVEL (semicolon, old ITEMGEN format) + int semiIdx = params.indexOf(';'); + if (semiIdx >= 0) { + try { lvl = Integer.parseInt(params.substring(semiIdx + 1)); } catch (NumberFormatException ignored) {} + params = params.substring(0, semiIdx); + } + + String[] parts = params.split(":", -1); + // parts[0] = amount + try { amt = Integer.parseInt(parts[0]); } catch (NumberFormatException ignored) {} + + boolean isItemGen = upper.startsWith("DIV_ITEMGEN_"); + boolean isCustom = upper.startsWith("DIV_CUSTOM_"); + + if (!isCustom && lvl < 0 && parts.length >= 2) { + if (isItemGen) { + // Last non-numeric part is material type + int endIdx = parts.length - 1; + if (!isNumericOrRange(parts[endIdx]) && endIdx >= 1) { + try { + materialType = CodexEngine.get().getItemManager().getItemType(parts[endIdx]); + } catch (Exception ignored) {} + endIdx--; + } + if (endIdx >= 1) { + lvl = parseFirstNumber(joinRange(parts, 1, endIdx + 1)); + } + } else { + lvl = parseFirstNumber(joinRange(parts, 1, parts.length)); + } + } + + base = base.substring(0, idStart) + id; + } + // else: no colon — base stays as DIV_MODULE_ID with default params + } + + result = parseDivModuleItem(itemString, base, lvl, amt, materialType); + if (result != null) return result; + throw new RuntimeException("DIV_ module item not found: " + base + + ". Verify the id exists in your Divinity configuration."); + } + CodexItemManager items = CodexEngine.get().getItemManager(); try { Matcher divMatcher = divinityPattern.matcher(itemString); @@ -80,6 +215,207 @@ static RecipeItem fromConfig(Object obj) { return result; } + /** + * Looks up the DIV_ module item and wraps it in a RecipeCustomItem. + * + * @param configKey original config string (stored as-is in YAML via toConfig()) + * @param base clean item ID string, e.g. "DIV_ITEMGEN_sword" (no params) + * @param level item level (-1 = random/unspecified) + * @param amount stack amount + * @param material optional material type for ITEMGEN (null = random) + */ + private static @Nullable RecipeItem parseDivModuleItem(String configKey, String base, int level, int amount, @Nullable ItemType material) { + String upper = base.toUpperCase(); + if (upper.startsWith("DIV_ITEMGEN_")) { + String name = base.substring("DIV_ITEMGEN_".length()); + boolean noEnch = upper.endsWith("_NOENCH"); + if (noEnch) name = name.substring(0, name.length() - "_NOENCH".length()); + var item = Divinity.getInstance().getModuleCache().getTierManager().getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_ITEMGEN item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, material, noEnch), amount, false, configKey); + } else if (upper.startsWith("DIV_GEM_")) { + String name = base.substring("DIV_GEM_".length()); + var item = Divinity.getInstance().getModuleCache().getGemManager().getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_GEM item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_ESSENCE_")) { + String name = base.substring("DIV_ESSENCE_".length()); + var item = Divinity.getInstance().getModuleCache().getEssenceManager().getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_ESSENCE item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_RUNE_")) { + String name = base.substring("DIV_RUNE_".length()); + var item = Divinity.getInstance().getModuleCache().getRuneManager().getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_RUNE item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_EXTRACTOR_")) { + String name = base.substring("DIV_EXTRACTOR_".length()); + var mgr = Divinity.getInstance().getModuleCache().getExtractManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_EXTRACTOR used but Extractor module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_EXTRACTOR item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_ARROW_")) { + String name = base.substring("DIV_ARROW_".length()); + var mgr = Divinity.getInstance().getModuleCache().getArrowManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_ARROW used but Arrow module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_ARROW item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_CONSUMABLE_")) { + String name = base.substring("DIV_CONSUMABLE_".length()); + var mgr = Divinity.getInstance().getModuleCache().getConsumablesManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_CONSUMABLE used but Consumables module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_CONSUMABLE item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_CUSTOM_")) { + String name = base.substring("DIV_CUSTOM_".length()); + var mgr = Divinity.getInstance().getModuleCache().getCustomItemsManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_CUSTOM used but CustomItems module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_CUSTOM item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_FORTIFY_")) { + String name = base.substring("DIV_FORTIFY_".length()); + var mgr = Divinity.getInstance().getModuleCache().getFortifyManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_FORTIFY used but Fortify module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_FORTIFY item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_IDENTIFY_")) { + String name = base.substring("DIV_IDENTIFY_".length()); + var mgr = Divinity.getInstance().getModuleCache().getIdentifyManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_IDENTIFY used but Identify module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_IDENTIFY item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_DUST_")) { + String name = base.substring("DIV_DUST_".length()); + var mgr = Divinity.getInstance().getModuleCache().getMagicDustManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_DUST used but MagicDust module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_DUST item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_REPAIR_")) { + String name = base.substring("DIV_REPAIR_".length()); + var mgr = Divinity.getInstance().getModuleCache().getRepairManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_REPAIR used but Repair module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_REPAIR item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_DISMANTLE_")) { + String name = base.substring("DIV_DISMANTLE_".length()); + var mgr = Divinity.getInstance().getModuleCache().getResolveManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_DISMANTLE used but Dismantle module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_DISMANTLE item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } else if (upper.startsWith("DIV_REFINE_")) { + String name = base.substring("DIV_REFINE_".length()); + var mgr = Divinity.getInstance().getModuleCache().getRefineManager(); + if (mgr == null) { + Fusion.getInstance().getLogger().warning("DIV_REFINE used but Refine module is not loaded."); + return null; + } + var item = mgr.getItemById(name.toLowerCase()); + if (item == null) { + Fusion.getInstance().getLogger().warning("DIV_REFINE item not found: " + name); + return null; + } + return new RecipeCustomItem(new DivinityModuleItemType(item, level, null, false), amount, false, configKey); + } + return null; + } + + private static boolean isNumericOrRange(String s) { + if (s == null || s.isEmpty()) return false; + for (String p : s.split(":")) { + try { Integer.parseInt(p.trim()); } catch (NumberFormatException e) { return false; } + } + return true; + } + + private static int parseFirstNumber(String s) { + if (s == null || s.isEmpty()) return -1; + try { return Integer.parseInt(s.split(":")[0]); } catch (NumberFormatException e) { return -1; } + } + + private static String joinRange(String[] parts, int from, int to) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to && i < parts.length; i++) { + if (sb.length() > 0) sb.append(':'); + sb.append(parts[i]); + } + return sb.toString(); + } + private static @Nullable RecipeItem buildInternalItem(Object obj) { RecipeItem result = null; String str = obj.toString(); diff --git a/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeTagItem.java b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeTagItem.java new file mode 100644 index 0000000..0f9a52b --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/data/recipes/RecipeTagItem.java @@ -0,0 +1,49 @@ +package studio.magemonkey.fusion.data.recipes; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import java.util.Set; + +/** + * A recipe ingredient that accepts any material belonging to a Bukkit {@link Tag}. + * YAML format: {@code TAG_:}, e.g. {@code TAG_PLANKS:4}. + * Tag names match Minecraft namespaced-key keys (lowercase, underscores). + */ +public class RecipeTagItem implements RecipeItem { + private final Tag tag; + private final String tagKey; + private final int amount; + + public RecipeTagItem(Tag tag, String tagKey, int amount) { + this.tag = tag; + this.tagKey = tagKey; + this.amount = amount; + } + + public Tag getTag() { + return tag; + } + + public String getTagKey() { + return tagKey; + } + + @Override + public int getAmount() { + return amount; + } + + @Override + public ItemStack getItemStack() { + Set values = tag.getValues(); + Material rep = values.isEmpty() ? Material.OAK_PLANKS : values.iterator().next(); + return new ItemStack(rep, amount); + } + + @Override + public Object toConfig() { + return "TAG_" + tagKey + ":" + amount; + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java b/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java index 1649e0b..5a694ac 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/CategoryGui.java @@ -106,13 +106,28 @@ public void reloadCategories() { allCategories.forEach((category) -> allCategoriesMap.putIfAbsent(category.getName(), new RecipeGui(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.inventory.setItem(slot, category.getDisplayIcon()); + // Separate categories into explicit-slot and auto-placed groups for the current page + int maxOnPage = Math.min(slots.length, allCategoryArray.length); + java.util.Set takenSlotIndices = new java.util.HashSet<>(); + for (int k = page * pageSize; k < allCategoryArray.length && (k - page * pageSize) < pageSize; k++) { + Category cat = allCategoryArray[k]; + int catSlot = cat.getSlot(); + if (catSlot >= 0 && catSlot < slots.length && !takenSlotIndices.contains(catSlot)) { + takenSlotIndices.add(catSlot); + int invSlot = slots[catSlot]; + this.categories.put(invSlot, new RecipeGui(player, table, cat)); + this.inventory.setItem(invSlot, cat.getDisplayIcon()); + } + } + int autoSlotIndex = 0; + for (int k = page * pageSize; k < allCategoryArray.length && i < maxOnPage; k++, i++) { + Category cat = allCategoryArray[k]; + if (cat.getSlot() >= 0 && cat.getSlot() < slots.length) continue; // already placed + while (autoSlotIndex < slots.length && takenSlotIndices.contains(autoSlotIndex)) autoSlotIndex++; + if (autoSlotIndex >= slots.length) break; + int invSlot = slots[autoSlotIndex++]; + this.categories.put(invSlot, new RecipeGui(player, table, cat)); + this.inventory.setItem(invSlot, cat.getDisplayIcon()); } for (int k = 0; k < inventory.getSize(); k++) { diff --git a/src/main/java/studio/magemonkey/fusion/gui/FuelGUI.java b/src/main/java/studio/magemonkey/fusion/gui/FuelGUI.java new file mode 100644 index 0000000..7641027 --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/gui/FuelGUI.java @@ -0,0 +1,190 @@ +package studio.magemonkey.fusion.gui; + +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.cfg.Cfg; +import studio.magemonkey.fusion.cfg.FuelItem; +import studio.magemonkey.fusion.cfg.FuelManager; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +/** + * GUI for the server fuel tank. + * + * Layout (9 slots per row): + * [fuel items — one slot each] [status item — always last slot] + * + * Players click a fuel item slot to consume one from their inventory and add fuel. + * The last slot always shows current/max fuel. + */ +public class FuelGUI implements Listener { + + private static final HashMap openGuis = new HashMap<>(); + + private final Player player; + private final Inventory inventory; + /** Maps inventory slot index → FuelItem for click handling */ + private final HashMap slotToFuelItem = new HashMap<>(); + + private FuelGUI(Player player) { + this.player = player; + int itemCount = Cfg.fuelItems.size(); + // Choose size: smallest multiple of 9 that fits all items + 1 status slot + int size = Math.max(9, (int) Math.ceil((itemCount + 1) / 9.0) * 9); + this.inventory = Bukkit.createInventory(null, size, ChatColor.GOLD + "Fuel"); + populate(); + player.openInventory(inventory); + openGuis.put(player.getUniqueId(), this); + } + + // ────────────────────────────────────────────── + // Open / close + // ────────────────────────────────────────────── + + public static void open(Player player) { + // Close existing instance if open + FuelGUI existing = openGuis.get(player.getUniqueId()); + if (existing != null) { + openGuis.remove(player.getUniqueId()); + } + FuelGUI gui = new FuelGUI(player); + Fusion.registerListener(gui); + } + + public static void closeAll() { + for (FuelGUI gui : new ArrayList<>(openGuis.values())) { + gui.player.closeInventory(); + } + openGuis.clear(); + } + + // ────────────────────────────────────────────── + // Rendering + // ────────────────────────────────────────────── + + private void populate() { + inventory.clear(); + slotToFuelItem.clear(); + + int size = inventory.getSize(); + + // Place fuel items in consecutive slots (all but the last) + List items = Cfg.fuelItems; + for (int i = 0; i < items.size() && i < size - 1; i++) { + FuelItem fi = items.get(i); + inventory.setItem(i, fi.createDisplayItem()); + slotToFuelItem.put(i, fi); + } + + // Fill remaining slots (between items and status) with glass pane + for (int i = items.size(); i < size - 1; i++) { + inventory.setItem(i, makeFiller()); + } + + // Last slot — status item + inventory.setItem(size - 1, makeStatusItem()); + } + + private ItemStack makeStatusItem() { + int current = FuelManager.getFuel(); + int max = FuelManager.getMaxFuel(); + + // Choose material based on fill level + Material mat; + if (current == 0) mat = Material.BARRIER; + else if (current < max / 3) mat = Material.RED_STAINED_GLASS; + else if (current < (max * 2) / 3) mat = Material.YELLOW_STAINED_GLASS; + else mat = Material.GREEN_STAINED_GLASS; + + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.GOLD + "⛽ Fuel"); + meta.setLore(List.of( + ChatColor.YELLOW + "Current: " + ChatColor.WHITE + current + " / " + max, + buildBar(current, max), + "", + ChatColor.GRAY + "Click fuel items on the left to add fuel" + )); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack makeFiller() { + ItemStack pane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta meta = pane.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.RESET + ""); + pane.setItemMeta(meta); + } + return pane; + } + + /** Renders a simple text bar: ██████░░░░ */ + private static String buildBar(int current, int max) { + int total = 20; + int filled = (max > 0) ? (int) Math.round((double) current / max * total) : 0; + StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "["); + for (int i = 0; i < total; i++) { + sb.append(i < filled ? ChatColor.GREEN + "█" : ChatColor.DARK_GRAY + "░"); + } + sb.append(ChatColor.YELLOW + "]"); + return sb.toString(); + } + + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + @EventHandler + public void onClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player p)) return; + if (!p.getUniqueId().equals(player.getUniqueId())) return; + if (!event.getInventory().equals(inventory)) return; + + event.setCancelled(true); + + int slot = event.getRawSlot(); + // Ignore clicks in the player's own inventory section + if (slot < 0 || slot >= inventory.getSize()) return; + + FuelItem fi = slotToFuelItem.get(slot); + if (fi == null) return; // clicked filler or status item + + FuelItem used = FuelManager.addFuelFromInventory(player, fi); + if (used == null) { + player.sendMessage(ChatColor.RED + "You don't have any " + fi.getMaterial().name() + " in your inventory."); + player.playSound(player.getLocation(), Sound.BLOCK_ANVIL_PLACE, 0.8f, 1f); + } else { + player.sendMessage(ChatColor.GREEN + "Added " + ChatColor.WHITE + used.getFuelAmount() + + ChatColor.GREEN + " fuel. Tank: " + ChatColor.WHITE + + FuelManager.getFuel() + "/" + FuelManager.getMaxFuel()); + player.playSound(player.getLocation(), Sound.BLOCK_FIRE_AMBIENT, 1f, 1.2f); + // Refresh status slot + inventory.setItem(inventory.getSize() - 1, makeStatusItem()); + } + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player p)) return; + if (!p.getUniqueId().equals(player.getUniqueId())) return; + if (!event.getInventory().equals(inventory)) return; + openGuis.remove(player.getUniqueId()); + } +} diff --git a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java index 91f91bd..20d693b 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/RecipeGui.java @@ -33,6 +33,7 @@ import studio.magemonkey.fusion.api.FusionAPI; import studio.magemonkey.fusion.cfg.Cfg; import studio.magemonkey.fusion.cfg.CraftingRequirementsCfg; +import studio.magemonkey.fusion.cfg.FuelManager; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.data.player.PlayerLoader; import studio.magemonkey.fusion.data.professions.pattern.Category; @@ -42,7 +43,12 @@ import studio.magemonkey.fusion.data.recipes.CalculatedRecipe; import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.data.recipes.Recipe; +import studio.magemonkey.divinity.stats.items.ItemStats; +import studio.magemonkey.fusion.cfg.hooks.HookType; +import studio.magemonkey.fusion.cfg.hooks.divinity.DivinityModuleItemType; +import studio.magemonkey.fusion.data.recipes.RecipeCustomItem; import studio.magemonkey.fusion.data.recipes.RecipeItem; +import studio.magemonkey.fusion.data.recipes.RecipeTagItem; import studio.magemonkey.fusion.gui.recipe.IngredientFingerprint; import studio.magemonkey.fusion.gui.recipe.InventoryFingerprint; import studio.magemonkey.fusion.gui.recipe.RecipeCacheKey; @@ -51,6 +57,7 @@ import studio.magemonkey.fusion.util.ChatUT; import studio.magemonkey.fusion.util.ExperienceManager; import studio.magemonkey.fusion.util.PlayerUtil; +import studio.magemonkey.fusion.util.StationChecker; import java.util.*; import java.util.stream.Collectors; @@ -380,14 +387,20 @@ public void reloadRecipes() { 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; + try { + CalculatedRecipe fresh = CalculatedRecipe.create( + recipe, + new HashMap<>(invCounts), + player, + table + ); + recipeCache.put(cacheKey, fresh); + calc = fresh; + } catch (Exception e) { + Fusion.getInstance().getLogger() + .warning("Failed to load recipe '" + recipe.getName() + "': " + e.getMessage()); + continue; + } } recipes.put(slotIndex, calc); @@ -661,6 +674,12 @@ private boolean canCraft(CalculatedRecipe calculatedRecipe, int slot) { return false; } + String station = recipe.getConditions().getStation(); + if (station != null && !station.isEmpty() && !StationChecker.hasStation(player, station)) { + player.sendMessage(ChatColor.RED + "YOU DON'T HAVE REQUIRED STATION"); + return false; + } + if (!Objects.equals(this.recipes.get(slot), calculatedRecipe)) { return false; } @@ -684,6 +703,13 @@ private boolean canCraft(CalculatedRecipe calculatedRecipe, int slot) { return false; } + int fuelCost = recipe.getConditions().getFuelCost(); + if (!FuelManager.hasFuel(fuelCost)) { + player.sendMessage(ChatColor.RED + "Not enough fuel! Need " + fuelCost + + ", have " + FuelManager.getFuel() + "."); + return false; + } + // Check queue limits int[] limits = PlayerLoader.getPlayer(player.getUniqueId()).getQueueSizes(table.getName(), category); int categoryLimit = @@ -711,6 +737,24 @@ private boolean canCraft(CalculatedRecipe calculatedRecipe, int slot) { return false; } + if (Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) { + for (RecipeItem required : recipe.getConditions().getRequiredItems()) { + if (!(required instanceof RecipeCustomItem rci)) continue; + if (!(rci.getItemType() instanceof DivinityModuleItemType dmt)) continue; + String requiredId = dmt.getModuleItem().getId(); + for (ItemStack invItem : player.getInventory().getContents()) { + if (invItem == null || invItem.getType().isAir()) continue; + if (!requiredId.equals(ItemStats.getId(invItem))) continue; + ItemMeta meta = invItem.getItemMeta(); + if (meta == null) continue; + if (!meta.getEnchants().isEmpty() || IngredientFingerprint.hasSocketFill(invItem)) { + player.sendMessage(ChatColor.RED + "You can't use an item with enchants, gems, essences or runes as an ingredient."); + return false; + } + } + } + } + return true; } @@ -734,10 +778,12 @@ private boolean craft(int slot, boolean addToCursor) { // 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 (meta != null) { + List lore = 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 @@ -755,7 +801,7 @@ private boolean craft(int slot, boolean addToCursor) { // // ─── 1) Build a local copy of the ingredient list ─── // - List requiredItems = new ArrayList<>(recipe.getItemsToTake()); + List requiredItems = new ArrayList<>(recipe.getConditions().getRequiredItems()); // Track exactly what we remove, so we can refund on failure List removedSoFar = new ArrayList<>(); @@ -765,16 +811,21 @@ private boolean craft(int slot, boolean addToCursor) { // // ─── 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 (RecipeItem required : requiredItems) { + int need = required.getAmount(); + IngredientFingerprint neededFingerprint = + (required instanceof RecipeTagItem) ? null : IngredientFingerprint.forRequired(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; + if (required instanceof RecipeTagItem tagItem) { + if (!tagItem.getTag().isTagged(slotStack.getType())) continue; + } else { + IngredientFingerprint slotFingerprint = IngredientFingerprint.of(slotStack); + if (!neededFingerprint.equals(slotFingerprint)) continue; + } int available = slotStack.getAmount(); int take = Math.min(available, need); @@ -786,7 +837,7 @@ private boolean craft(int slot, boolean addToCursor) { inv.setItem(slotIndex, slotStack); } - ItemStack actuallyTaken = required.clone(); + ItemStack actuallyTaken = slotStack.clone(); actuallyTaken.setAmount(take); removedSoFar.add(actuallyTaken); @@ -830,7 +881,7 @@ private boolean craft(int slot, boolean addToCursor) { recipe.getCraftingTime() - (recipe.getCraftingTime() * modifier) ); - showBossBar(this.player, recipe.getSettings().getRecipeItem().getItemStack(), cooldown); + showBossBar(this.player, resultItem, cooldown); if (cooldown != 0) { previousCursor = player.getOpenInventory().getCursor(); @@ -845,7 +896,7 @@ private boolean craft(int slot, boolean addToCursor) { if (recipe.getResults().getCommands().isEmpty()) { if (addToCursor) { ItemStack cursor = this.player.getItemOnCursor(); - if (cursor.isSimilar(recipe.getSettings().getRecipeItem().getItemStack())) { + if (cursor.isSimilar(resultItem)) { if (cursor.getAmount() < cursor.getMaxStackSize() && cursor.getAmount() + recipe.getSettings().getRecipeItem().getAmount() <= cursor.getMaxStackSize()) { @@ -877,6 +928,7 @@ private boolean craft(int slot, boolean addToCursor) { if (craftingSuccess) { cancel(false); CodexEngine.get().getVault().take(this.player, recipe.getConditions().getMoneyCost()); + FuelManager.consumeFuel(recipe.getConditions().getFuelCost()); // Commands DelayedCommand.invoke(Fusion.getInstance(), player, recipe.getResults().getCommands()); @@ -907,6 +959,7 @@ 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()); + FuelManager.consumeFuel(recipe.getConditions().getFuelCost()); this.queue.addRecipe(this.recipes.get(slot).getRecipe()); } return true; @@ -1045,7 +1098,6 @@ public void click(InventoryClickEvent event) { event.setCancelled(true); event.setResult(Event.Result.DENY); Fusion.getInstance().runSync(() -> { - this.reloadRecipes(); this.craft(event.getRawSlot(), false); this.reloadRecipesTask(); }); diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/Editor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/Editor.java index 0aafefa..7b7a488 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/Editor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/Editor.java @@ -4,8 +4,11 @@ import lombok.Setter; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; +import studio.magemonkey.fusion.cfg.editors.EditorRegistry; import java.util.ArrayList; import java.util.List; @@ -24,6 +27,8 @@ public class Editor { @Setter private List nestedInventories; + protected boolean suppressCloseNav = false; + public Editor(Editor parentEditor, String title, int size) { this.parentEditor = parentEditor; this.title = title; @@ -31,6 +36,7 @@ public Editor(Editor parentEditor, String title, int size) { this.inventory = Bukkit.createInventory(null, size, title); this.nestedInventories = new ArrayList<>(); + EditorRegistry.registerInventory(this.inventory, this); } public void setItem(int slot, ItemStack item) { @@ -43,7 +49,37 @@ public void open(Player player) { public void openParent(Player player) { if (parentEditor != null) { + suppressCloseNav = true; + dispose(); parentEditor.open(player); } } + + public void suppressNextClose() { + this.suppressCloseNav = true; + } + + /** + * Unregisters this editor's event listeners and removes its inventories from the registry. + * Call this when navigating away from this editor permanently. + */ + public void dispose() { + if (this instanceof Listener listener) { + HandlerList.unregisterAll(listener); + } + EditorRegistry.unregisterInventory(this.inventory); + if (nestedInventories != null) { + for (Inventory nested : nestedInventories) { + EditorRegistry.unregisterInventory(nested); + } + } + } + + public Editor getRootEditor() { + Editor current = this; + while (current.getParentEditor() != null) { + current = current.getParentEditor(); + } + return current; + } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/browse/BrowseProfessionsEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/browse/BrowseProfessionsEditor.java index fffcdd5..66b4242 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/browse/BrowseProfessionsEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/browse/BrowseProfessionsEditor.java @@ -16,9 +16,10 @@ import studio.magemonkey.fusion.util.InventoryUtils; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class BrowseProfessionsEditor extends Editor implements Listener { @@ -29,6 +30,8 @@ public class BrowseProfessionsEditor extends Editor implements Listener { private BrowseProfessionEditor browseProfessionEditor; private final HashMap> slots = new HashMap<>(); + private List professionList = new ArrayList<>(); + private final Set populatedPages = new HashSet<>(); public BrowseProfessionsEditor(BrowseEditor browseEditor, Player player) { super(browseEditor, EditorRegistry.getBrowseProfessionCfg().getTitle(), 54); @@ -42,43 +45,25 @@ public BrowseProfessionsEditor(BrowseEditor browseEditor, Player player) { public void initialize() { slots.clear(); + populatedPages.clear(); getNestedInventories().clear(); - HashMap invSlots = new HashMap<>(); - List inventories = new ArrayList<>(); - Inventory inv = null; - int invSlot = 0; - Collection professions = browseEditor.getProfessionConditions().values(); - for (ProfessionConditions entry : professions) { + professionList = new ArrayList<>(); + for (ProfessionConditions entry : browseEditor.getProfessionConditions().values()) { if (!ProfessionsCfg.getMap().containsKey(entry.getProfession())) { - Fusion.getInstance() - .getLogger() + Fusion.getInstance().getLogger() .warning("Profession " + entry.getProfession() + " not found for BrowseEditor. You might want to remove it from browse.yml to avoid problems."); Fusion.getInstance().getLogger().warning("Skipping profession: " + entry.getProfession()); continue; } - int invDex = invSlot % 36 + 9; - if (invDex == 9) { - if (inv != null) - inventories.add(inv); - inv = InventoryUtils.createFilledInventory(null, - EditorRegistry.getBrowseProfessionCfg().getTitle(), - 54, - getIcons().get("fill")); - inv.setItem(4, getIcons().get("add")); - inv.setItem(48, getIcons().get("previous")); - inv.setItem(50, getIcons().get("next")); - inv.setItem(53, getIcons().get("back")); - invSlots = new HashMap<>(); - } - inv.setItem(invDex, EditorRegistry.getBrowseProfessionCfg().getProfessionIcon(entry)); - invSlots.put(invDex, entry); - slots.put(inv, invSlots); - invSlot++; + professionList.add(entry); } - if (inv == null) { - inv = InventoryUtils.createFilledInventory(null, + + int pageCount = professionList.isEmpty() ? 1 : (int) Math.ceil(professionList.size() / 36.0); + List inventories = new ArrayList<>(); + for (int p = 0; p < pageCount; p++) { + Inventory inv = InventoryUtils.createFilledInventory(null, EditorRegistry.getBrowseProfessionCfg().getTitle(), 54, getIcons().get("fill")); @@ -86,18 +71,38 @@ public void initialize() { inv.setItem(48, getIcons().get("previous")); inv.setItem(50, getIcons().get("next")); inv.setItem(53, getIcons().get("back")); + inventories.add(inv); } - inventories.add(inv); setNestedInventories(inventories); } + private void populatePage(int page) { + if (page < 0 || page >= getNestedInventories().size()) return; + Inventory inv = getNestedInventories().get(page); + HashMap invSlots = new HashMap<>(); + int start = page * 36; + int end = Math.min(start + 36, professionList.size()); + for (int i = start; i < end; i++) { + ProfessionConditions entry = professionList.get(i); + int invDex = (i - start) + 9; + inv.setItem(invDex, EditorRegistry.getBrowseProfessionCfg().getProfessionIcon(entry)); + invSlots.put(invDex, entry); + } + slots.put(inv, invSlots); + populatedPages.add(page); + } + public void open(Player player) { open(player, 0); } public void open(Player player, int page) { - player.openInventory(getNestedInventories().get(page) != null ? getNestedInventories().get(page) - : getNestedInventories().get(page - 1)); + List invs = getNestedInventories(); + int safePage = (page >= 0 && page < invs.size()) ? page : invs.size() - 1; + if (!populatedPages.contains(safePage)) { + populatePage(safePage); + } + player.openInventory(invs.get(safePage)); } @EventHandler diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternEditor.java index 06e36f1..dd68286 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternEditor.java @@ -13,6 +13,7 @@ import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.gui.editors.Editor; import studio.magemonkey.fusion.gui.editors.browse.BrowseEditor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import java.util.HashMap; import java.util.Map; @@ -116,6 +117,8 @@ public void onInventoryClick(InventoryClickEvent event) { else setIcons(EditorRegistry.getPatternEditorCfg().getIcons(browseEditor)); initialize(); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemEditor.java index c372d10..c4c96d3 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemEditor.java @@ -1,11 +1,13 @@ package studio.magemonkey.fusion.gui.editors.pattern; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; import studio.magemonkey.codex.api.DelayedCommand; @@ -18,6 +20,7 @@ import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.gui.editors.Editor; import studio.magemonkey.fusion.gui.editors.browse.BrowseEditor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.ArrayList; @@ -141,6 +144,7 @@ public void onInventoryClick(InventoryClickEvent event) { } case 26 -> { reload(false); + suppressCloseNav = true; ((PatternItemsEditor) getParentEditor()).reload(true); return; } @@ -188,16 +192,28 @@ public void onInventoryClick(InventoryClickEvent event) { } } case 44 -> { - getParentEditor().open(player); + openParent(player); return; } } if (hasChanges) { reload(true); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; + } + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> openParent(player), 1); + } + public void reload(boolean open) { if (table != null) { if (isCategoryPattern) { diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemsEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemsEditor.java index 4644934..81ff1d1 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemsEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/pattern/PatternItemsEditor.java @@ -1,11 +1,13 @@ package studio.magemonkey.fusion.gui.editors.pattern; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemStack; import studio.magemonkey.codex.CodexEngine; import studio.magemonkey.fusion.Fusion; @@ -14,6 +16,7 @@ import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.gui.editors.Editor; import studio.magemonkey.fusion.gui.editors.browse.BrowseEditor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.*; @@ -166,6 +169,7 @@ else if (browseEditor != null) player, slots.get(event.getSlot())); } + suppressCloseNav = true; patternItemEditor.open(player); } else { char c = slots.get(event.getSlot()); @@ -208,9 +212,21 @@ else if (browseEditor != null) if (hasChanges) { reload(false); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; + } + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> openParent(player), 1); + } + public void reload(boolean open) { if (table != null) setIcons(EditorRegistry.getPatternItemEditorCfg().getIcons(table)); diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/CategoryEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/CategoryEditor.java index 30cbf00..9769d0d 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/CategoryEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/CategoryEditor.java @@ -1,11 +1,13 @@ package studio.magemonkey.fusion.gui.editors.professions; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemStack; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.editors.EditorCriteria; @@ -14,6 +16,7 @@ import studio.magemonkey.fusion.data.professions.pattern.Category; import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.gui.editors.Editor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.HashMap; @@ -130,13 +133,27 @@ public void onInventoryClick(InventoryClickEvent event) { if (hasChanges) { reload(true); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; + } + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> openParent(player), 1); + } + public void reload(boolean open) { - setIcons(EditorRegistry.getPatternItemEditorCfg().getIcons(table)); + setIcons(EditorRegistry.getCategoryEditorCfg().getIcons(table)); initialize(); - if (open) + if (open) { + suppressCloseNav = true; open(player); + } } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/ProfessionEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/ProfessionEditor.java index 3f4f6b8..b06a026 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/ProfessionEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/ProfessionEditor.java @@ -1,23 +1,26 @@ package studio.magemonkey.fusion.gui.editors.professions; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; -import studio.magemonkey.codex.CodexEngine; -import studio.magemonkey.codex.util.messages.MessageData; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.scheduler.BukkitTask; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.cfg.editors.EditorCriteria; import studio.magemonkey.fusion.cfg.editors.EditorRegistry; import studio.magemonkey.fusion.commands.FusionEditorCommand; import studio.magemonkey.fusion.data.recipes.CraftingTable; +import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; import studio.magemonkey.fusion.gui.editors.Editor; import studio.magemonkey.fusion.gui.editors.pattern.PatternEditor; import studio.magemonkey.fusion.gui.editors.pattern.PatternItemsEditor; import studio.magemonkey.fusion.gui.editors.professions.recipes.RecipeEditor; import studio.magemonkey.fusion.util.InventoryUtils; +import studio.magemonkey.fusion.util.TabCacher; @Getter public class ProfessionEditor extends Editor implements Listener { @@ -26,6 +29,8 @@ public class ProfessionEditor extends Editor implements Listener { private final String profession; private final CraftingTable table; + private BukkitTask pendingSaveTask; + private PatternItemsEditor patternItemsEditor; private PatternEditor patternEditor; private PatternItemsEditor categoryPatternItemEditor; @@ -37,15 +42,38 @@ public ProfessionEditor(Player player, String profession) { super(null, EditorRegistry.getProfessionEditorCfg().getTitle(profession), 45); this.player = player; this.profession = profession; - // Copy the table to prevent changes to the original table while people do crafting - this.table = CraftingTable.copy(ProfessionsCfg.getTable(profession)); - table.cleanUpRecipesForEditor(); + // Copy the table, deduplicating ItemGen pseudo-recipes (name::material) in one pass + this.table = CraftingTable.copyForEditor(ProfessionsCfg.getTable(profession)); setIcons(EditorRegistry.getProfessionEditorCfg().getIcons(table)); initialize(); Fusion.registerListener(this); } + public void autoSave() { + if (pendingSaveTask != null) { + pendingSaveTask.cancel(); + } + pendingSaveTask = Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), this::saveImmediate, 20L); + } + + public void saveNow() { + if (pendingSaveTask != null) { + pendingSaveTask.cancel(); + pendingSaveTask = null; + } + saveImmediate(); + } + + private void saveImmediate() { + pendingSaveTask = null; + table.save(() -> { + ProfessionsCfg.getMap().put(profession, table); + ProfessionsCfg.getGuiMap().put(profession, new ProfessionGuiRegistry(profession)); + TabCacher.clearAllCaches("professions"); + }); + } + private void initialize() { InventoryUtils.fillInventory(getInventory(), getIcons().get("fill")); setItem(10, getIcons().get("name")); @@ -61,7 +89,6 @@ private void initialize() { setItem(32, getIcons().get("categories")); setItem(33, getIcons().get("categoryPatternItems")); setItem(34, getIcons().get("categoryPattern")); - setItem(36, getIcons().get("save")); setItem(44, getIcons().get("back")); } @@ -113,8 +140,9 @@ public void onInventoryClick(InventoryClickEvent event) { } } case 16 -> { - if (recipeEditor == null) - recipeEditor = new RecipeEditor(this, player, table); + if (recipeEditor != null) recipeEditor.dispose(); + recipeEditor = new RecipeEditor(this, player, table); + suppressCloseNav = true; recipeEditor.open(player); } case 28 -> { @@ -122,29 +150,34 @@ public void onInventoryClick(InventoryClickEvent event) { hasChanges = true; } case 29 -> { - if (patternItemsEditor == null) - patternItemsEditor = new PatternItemsEditor(this, player, table, false); + if (patternItemsEditor != null) patternItemsEditor.dispose(); + patternItemsEditor = new PatternItemsEditor(this, player, table, false); + suppressCloseNav = true; patternItemsEditor.open(player); } case 30 -> { - if (patternEditor == null) - patternEditor = new PatternEditor(this, player, table, false); + if (patternEditor != null) patternEditor.dispose(); + patternEditor = new PatternEditor(this, player, table, false); + suppressCloseNav = true; patternEditor.open(player); } case 32 -> { - if (categoryEditor == null) - categoryEditor = new CategoryEditor(this, player, table); + if (categoryEditor != null) categoryEditor.dispose(); + categoryEditor = new CategoryEditor(this, player, table); + suppressCloseNav = true; categoryEditor.open(player); } case 33 -> { - if (categoryPatternItemEditor == null) - categoryPatternItemEditor = new PatternItemsEditor(this, player, table, true); + if (categoryPatternItemEditor != null) categoryPatternItemEditor.dispose(); + categoryPatternItemEditor = new PatternItemsEditor(this, player, table, true); + suppressCloseNav = true; categoryPatternItemEditor.open(player); } case 34 -> { if (event.isLeftClick()) { - if (categoryPatternEditor == null) - categoryPatternEditor = new PatternEditor(this, player, table, true); + if (categoryPatternEditor != null) categoryPatternEditor.dispose(); + categoryPatternEditor = new PatternEditor(this, player, table, true); + suppressCloseNav = true; categoryPatternEditor.open(player); } else if (event.isRightClick()) { if (table.getCatPattern() != null) { @@ -152,32 +185,41 @@ public void onInventoryClick(InventoryClickEvent event) { } } } - case 36 -> table.save(() -> { + case 44 -> { + saveNow(); + suppressCloseNav = true; + dispose(); player.closeInventory(); - CodexEngine.get() - .getMessageUtil() - .sendMessage("editor.changesSaved", - player, - new MessageData("file", ProfessionsCfg.getFiles().get(profession).getName())); EditorRegistry.removeCurrentEditor(player); FusionEditorCommand.removeEditorCriteria(player.getUniqueId()); - ProfessionsCfg.init(); - }); - case 44 -> { - player.closeInventory(); - hasChanges = true; } } if (hasChanges) { reload(true); + autoSave(); } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; + } + saveNow(); + EditorRegistry.removeCurrentEditor(player); + FusionEditorCommand.removeEditorCriteria(player.getUniqueId()); + dispose(); + } + public void reload(boolean open) { setIcons(EditorRegistry.getProfessionEditorCfg().getIcons(table)); initialize(); - if (open) + if (open) { + suppressCloseNav = true; open(player); + } } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeEditor.java index de7a66d..deecc6e 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeEditor.java @@ -1,10 +1,12 @@ package studio.magemonkey.fusion.gui.editors.professions.recipes; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.Inventory; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.editors.EditorCriteria; @@ -13,12 +15,14 @@ import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.data.recipes.Recipe; import studio.magemonkey.fusion.gui.editors.Editor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class RecipeEditor extends Editor implements Listener { @@ -30,6 +34,8 @@ public class RecipeEditor extends Editor implements Listener { private RecipeItemEditor recipeItemEditor; private final HashMap> slots = new HashMap<>(); + private List recipeList = new ArrayList<>(); + private final Set populatedPages = new HashSet<>(); public RecipeEditor(Editor parentEditor, Player player, CraftingTable table) { super(parentEditor, EditorRegistry.getRecipeEditorCfg().getTitle(), 54); @@ -43,35 +49,18 @@ public RecipeEditor(Editor parentEditor, Player player, CraftingTable table) { public void initialize() { slots.clear(); + populatedPages.clear(); + for (Inventory old : getNestedInventories()) { + EditorRegistry.unregisterInventory(old); + } getNestedInventories().clear(); - HashMap invSlots = new HashMap<>(); - List inventories = new ArrayList<>(); - Inventory inv = null; - int invSlot = 0; - Collection recipes = table.getRecipes().values(); - for (Recipe entry : recipes) { - int invDex = invSlot % 36 + 9; - if (invDex == 9) { - if (inv != null) - inventories.add(inv); - inv = InventoryUtils.createFilledInventory(null, - EditorRegistry.getRecipeEditorCfg().getTitle(), - 54, - getIcons().get("fill")); - inv.setItem(4, getIcons().get("add")); - inv.setItem(48, getIcons().get("previous")); - inv.setItem(50, getIcons().get("next")); - inv.setItem(53, getIcons().get("back")); - invSlots = new HashMap<>(); - } - inv.setItem(invDex, EditorRegistry.getRecipeEditorCfg().getRecipeIcon(entry)); - invSlots.put(invDex, entry); - slots.put(inv, invSlots); - invSlot++; - } - if (inv == null) { - inv = InventoryUtils.createFilledInventory(null, + recipeList = new ArrayList<>(table.getRecipes().values()); + + int pageCount = recipeList.isEmpty() ? 1 : (int) Math.ceil(recipeList.size() / 36.0); + List inventories = new ArrayList<>(); + for (int p = 0; p < pageCount; p++) { + Inventory inv = InventoryUtils.createFilledInventory(null, EditorRegistry.getRecipeEditorCfg().getTitle(), 54, getIcons().get("fill")); @@ -79,9 +68,28 @@ public void initialize() { inv.setItem(48, getIcons().get("previous")); inv.setItem(50, getIcons().get("next")); inv.setItem(53, getIcons().get("back")); + inventories.add(inv); } - inventories.add(inv); setNestedInventories(inventories); + for (Inventory nested : inventories) { + EditorRegistry.registerInventory(nested, this); + } + } + + private void populatePage(int page) { + if (page < 0 || page >= getNestedInventories().size()) return; + Inventory inv = getNestedInventories().get(page); + HashMap invSlots = new HashMap<>(); + int start = page * 36; + int end = Math.min(start + 36, recipeList.size()); + for (int i = start; i < end; i++) { + Recipe entry = recipeList.get(i); + int invDex = (i - start) + 9; + inv.setItem(invDex, EditorRegistry.getRecipeEditorCfg().getRecipeIcon(entry)); + invSlots.put(invDex, entry); + } + slots.put(inv, invSlots); + populatedPages.add(page); } public void open(Player player) { @@ -89,8 +97,13 @@ public void open(Player player) { } public void open(Player player, int page) { - player.openInventory(getNestedInventories().get(page) != null ? getNestedInventories().get(page) - : getNestedInventories().get(page - 1)); + List invs = getNestedInventories(); + int safePage = (page >= 0 && page < invs.size()) ? page : invs.size() - 1; + if (!populatedPages.contains(safePage)) { + populatePage(safePage); + } + suppressCloseNav = true; + player.openInventory(invs.get(safePage)); } @EventHandler @@ -117,6 +130,7 @@ public void onInventoryClick(InventoryClickEvent event) { if (!event.isShiftClick()) { if (event.isLeftClick()) { recipeItemEditor = new RecipeItemEditor(this, player, entry); + suppressCloseNav = true; recipeItemEditor.open(player); } else if (event.isRightClick()) { table.getRecipes().remove(entry.getName()); @@ -140,7 +154,23 @@ public void onInventoryClick(InventoryClickEvent event) { if (hasChanges) { reload(true); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); + } + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (!getNestedInventories().contains(event.getInventory())) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; } + // Fix B: bail if editor was already closed via command (prevents reopening after closeAllEditors) + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> { + if (EditorRegistry.getCurrentEditor(player) == null) return; + openParent(player); + }, 1); } public void reload(boolean open) { diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeIconEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeIconEditor.java index a466071..71511be 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeIconEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeIconEditor.java @@ -1,11 +1,13 @@ package studio.magemonkey.fusion.gui.editors.professions.recipes; import lombok.Getter; +import org.bukkit.Bukkit; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.inventory.ItemFlag; import studio.magemonkey.codex.api.DelayedCommand; import studio.magemonkey.fusion.Fusion; @@ -14,6 +16,7 @@ import studio.magemonkey.fusion.commands.FusionEditorCommand; import studio.magemonkey.fusion.data.recipes.Recipe; import studio.magemonkey.fusion.gui.editors.Editor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.ArrayList; @@ -161,14 +164,26 @@ public void onInventoryClick(InventoryClickEvent event) { } } case 44 -> { - getParentEditor().open(player); + openParent(player); return; } } if (hasChanges) { reload(true); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); + } + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; } + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> openParent(player), 1); } public void reload(boolean open) { diff --git a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeItemEditor.java b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeItemEditor.java index 7731441..d5fefcd 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeItemEditor.java +++ b/src/main/java/studio/magemonkey/fusion/gui/editors/professions/recipes/RecipeItemEditor.java @@ -5,6 +5,8 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.Bukkit; +import org.bukkit.event.inventory.InventoryCloseEvent; import studio.magemonkey.codex.CodexEngine; import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.editors.EditorCriteria; @@ -12,6 +14,7 @@ import studio.magemonkey.fusion.commands.FusionEditorCommand; import studio.magemonkey.fusion.data.recipes.Recipe; import studio.magemonkey.fusion.gui.editors.Editor; +import studio.magemonkey.fusion.gui.editors.professions.ProfessionEditor; import studio.magemonkey.fusion.util.InventoryUtils; import java.util.List; @@ -56,6 +59,11 @@ public void initialize() { setItem(41, getIcons().get("mastery")); setItem(42, getIcons().get("permission")); setItem(43, getIcons().get("conditions")); + setItem(35, getIcons().get("highlight")); + setItem(36, getIcons().get("highlight")); + setItem(44, getIcons().get("station")); + setItem(45, getIcons().get("fuelCost")); + setItem(46, getIcons().get("highlight")); setItem(49, getIcons().get("category")); setItem(53, getIcons().get("back")); @@ -63,7 +71,7 @@ public void initialize() { @EventHandler public void onInventoryClick(InventoryClickEvent event) { - if (event.getClickedInventory() != getInventory()) return; + if (event.getView().getTopInventory() != getInventory()) return; event.setCancelled(true); Player player = (Player) event.getWhoClicked(); boolean hasChanges = false; @@ -134,6 +142,7 @@ public void onInventoryClick(InventoryClickEvent event) { "/fusion-editor " + getRecipeName() + " " + getRecipeAmount()); else if (event.isRightClick()) { recipeIconEditor = new RecipeIconEditor(this, player, recipe); + suppressCloseNav = true; recipeIconEditor.open(player); } } @@ -267,6 +276,27 @@ else if (event.isRightClick()) { hasChanges = true; } } + case 44 -> { + if (event.isLeftClick()) + FusionEditorCommand.suggestUsage(player, + EditorCriteria.Profession_Recipe_Edit_Station, + "/fusion-editor "); + else if (event.isRightClick()) { + recipe.getConditions().setStation(null); + hasChanges = true; + } + } + case 45 -> { + int amount = event.isShiftClick() ? 10 : 1; + if (event.isLeftClick()) { + recipe.getConditions().setFuelCost(recipe.getConditions().getFuelCost() + amount); + hasChanges = true; + } else if (event.isRightClick()) { + if (recipe.getConditions().getFuelCost() == 0) return; + recipe.getConditions().setFuelCost(Math.max(recipe.getConditions().getFuelCost() - amount, 0)); + hasChanges = true; + } + } case 49 -> { List categories = ((RecipeEditor) getParentEditor()).getTable().getCategoryList(); String currentCategory = recipe.getCategory(); @@ -289,6 +319,7 @@ else if (event.isRightClick()) { } case 53 -> { reload(false); + suppressCloseNav = true; ((RecipeEditor) getParentEditor()).reload(true); return; } @@ -296,14 +327,28 @@ else if (event.isRightClick()) { if (hasChanges) { reload(true); + Editor root = getRootEditor(); + if (root instanceof ProfessionEditor) ((ProfessionEditor) root).autoSave(); } } + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getInventory() != getInventory()) return; + if (suppressCloseNav) { + suppressCloseNav = false; + return; + } + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), () -> openParent(player), 1); + } + public void reload(boolean open) { setIcons(EditorRegistry.getRecipeEditorCfg().getSubIcons(recipe)); initialize(); - if (open) + if (open) { + suppressCloseNav = true; open(player); + } } public String getRecipeName() { diff --git a/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java b/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java index ff9de7e..33f60f5 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/IngredientFingerprint.java @@ -1,22 +1,25 @@ package studio.magemonkey.fusion.gui.recipe; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import studio.magemonkey.codex.util.DataUT; +import studio.magemonkey.fusion.cfg.hooks.divinity.DivinityModuleItemType; +import studio.magemonkey.fusion.data.recipes.RecipeCustomItem; +import studio.magemonkey.fusion.data.recipes.RecipeItem; import java.util.*; /** * Immutable fingerprint for an ItemStack that matches CalculatedRecipe.isSimilar(...) logic. *

- * We compare: - * - Material - * - customModelData (if present) - * - displayName (if present) - * - lore lines (if present) - * - all enchantments (if present) - * - unbreakable flag - * - durability (if Damageable) + * Special handling for Divinity items (item_generator, gems, essences, runes): only the + * item_id and level are compared, ignoring variable stats like lore or enchantments. + * A divinityItemLevel of -1 acts as a wildcard ("any level") in matching — used for + * recipe ingredients that do not specify a level constraint. */ public class IngredientFingerprint { private final Material type; @@ -26,6 +29,20 @@ public class IngredientFingerprint { private final Map enchantments; private final boolean unbreakable; private final int durability; + private final boolean hasSocketFill; + + // --- Divinity-specific fields --- + private final String divinityItemId; // null if not a Divinity item + private final int divinityItemLevel; // -1 means "any level" (wildcard) or not a Divinity item + + private static final NamespacedKey DIVINITY_MODULE_KEY = new NamespacedKey("divinity", "item_module"); + private static final NamespacedKey DIVINITY_ITEM_ID_KEY = new NamespacedKey("divinity", "item_id"); + private static final NamespacedKey DIVINITY_ITEM_LEVEL_KEY = new NamespacedKey("divinity", "item_level"); + private static final String DIVINITY_MODULE_ITEMGEN = "item_generator"; + private static final String DIVINITY_MODULE_GEMS = "gems"; + private static final String DIVINITY_MODULE_ESSENCES = "essences"; + private static final String DIVINITY_MODULE_RUNES = "runes"; + private static final String DIVINITY_MODULE_EXTRACTOR = "extractor"; public IngredientFingerprint(Material type, int customModelData, @@ -33,7 +50,10 @@ public IngredientFingerprint(Material type, List lore, Map enchantments, boolean unbreakable, - int durability) { + int durability, + boolean hasSocketFill, + String divinityItemId, + int divinityItemLevel) { this.type = type; this.customModelData = customModelData; this.displayName = (displayName == null ? "" : displayName); @@ -41,10 +61,43 @@ public IngredientFingerprint(Material type, this.enchantments = (enchantments == null ? Collections.emptyMap() : new HashMap<>(enchantments)); this.unbreakable = unbreakable; this.durability = durability; + this.hasSocketFill = hasSocketFill; + this.divinityItemId = divinityItemId; + this.divinityItemLevel = divinityItemLevel; + } + + public static boolean hasSocketFill(ItemStack item) { + if (item == null) return false; + return hasSocketFill(item.getItemMeta()); + } + + private static boolean hasSocketFill(ItemMeta meta) { + if (meta == null) return false; + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + for (NamespacedKey key : pdc.getKeys()) { + if (!key.getNamespace().equals("divinity")) continue; + String k = key.getKey().toLowerCase(); + if (!k.startsWith("item_socket_gem_") + && !k.startsWith("item_socket_rune_") + && !k.startsWith("item_socket_essence_")) continue; + String[] value = pdc.get(key, DataUT.STRING_ARRAY); + if (value != null && value.length == 2 && !value[0].isEmpty() && !value[1].isEmpty()) { + return true; + } + } + return false; + } + + private static boolean isDivinityModule(String module) { + return DIVINITY_MODULE_ITEMGEN.equalsIgnoreCase(module) + || DIVINITY_MODULE_GEMS.equalsIgnoreCase(module) + || DIVINITY_MODULE_ESSENCES.equalsIgnoreCase(module) + || DIVINITY_MODULE_RUNES.equalsIgnoreCase(module) + || DIVINITY_MODULE_EXTRACTOR.equalsIgnoreCase(module); } /** - * Build an IngredientFingerprint by examining a live ItemStack. + * Build an IngredientFingerprint by examining a live ItemStack from a player's inventory. */ public static IngredientFingerprint of(ItemStack is) { Material mat = is.getType(); @@ -57,6 +110,9 @@ public static IngredientFingerprint of(ItemStack is) { boolean unbreak = false; int dmg = 0; + String divId = null; + int divLevel = -1; + if (meta != null) { if (meta.hasCustomModelData()) { cmd = meta.getCustomModelData(); @@ -75,15 +131,123 @@ public static IngredientFingerprint of(ItemStack is) { if (meta instanceof org.bukkit.inventory.meta.Damageable dmeta) { dmg = dmeta.getDamage(); } + + // Detect Divinity items (item_generator, gems, essences, runes) + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + String module = pdc.get(DIVINITY_MODULE_KEY, PersistentDataType.STRING); + if (isDivinityModule(module)) { + String itemId = pdc.get(DIVINITY_ITEM_ID_KEY, PersistentDataType.STRING); + if (itemId != null && !itemId.isEmpty()) { + divId = itemId.toLowerCase(); + } + Integer level = pdc.get(DIVINITY_ITEM_LEVEL_KEY, PersistentDataType.INTEGER); + if (level != null) { + divLevel = level; + } + } } - return new IngredientFingerprint(mat, cmd, name, loreList, enchantsMap, unbreak, dmg); + return new IngredientFingerprint(mat, + cmd, + name, + loreList, + enchantsMap, + unbreak, + dmg, + divId != null && hasSocketFill(meta), // non-Divinity items can't have Divinity sockets + divId, + divLevel); + } + + /** + * Build a fingerprint for a recipe ingredient requirement — avoids calling getItemStack() + * on Divinity items (which would generate a random-level item when level=-1). + * The resulting fingerprint uses level=-1 as a wildcard matching any actual item level. + */ + public static IngredientFingerprint forRequired(RecipeItem required) { + if (required instanceof RecipeCustomItem rci + && rci.getItemType() instanceof DivinityModuleItemType dmt + && dmt.getModuleItem() != null) { + return new IngredientFingerprint( + Material.AIR, 0, "", Collections.emptyList(), + Collections.emptyMap(), false, 0, false, + dmt.getModuleItem().getId().toLowerCase(), + dmt.getLevel()); + } + ItemStack single = required.getItemStack().clone(); + single.setAmount(1); + return of(single); + } + + /** + * Sum all values in the map whose keys match this fingerprint. + * Required when this fingerprint has divinityItemLevel=-1 (any level), since multiple + * inventory entries with different specific levels all map to different HashMap buckets + * under the old hashCode, but must all be counted. + */ + public int sumMatchingEntries(Map map) { + if (divinityItemId != null && divinityItemLevel == -1) { + int total = 0; + for (Map.Entry e : map.entrySet()) { + IngredientFingerprint k = e.getKey(); + if (k.divinityItemId != null && k.divinityItemId.equals(this.divinityItemId)) { + total += e.getValue(); + } + } + return total; + } + return map.getOrDefault(this, 0); + } + + /** + * Drain `need` units from the map for entries matching this fingerprint. + * Handles the wildcard (level=-1) case by draining across all matching specific-level entries. + */ + public void drainFromMap(Map map, int need) { + if (divinityItemId != null && divinityItemLevel == -1) { + for (Iterator> it = map.entrySet().iterator(); + it.hasNext() && need > 0; ) { + Map.Entry e = it.next(); + IngredientFingerprint k = e.getKey(); + if (k.divinityItemId != null && k.divinityItemId.equals(this.divinityItemId)) { + int drain = Math.min(e.getValue(), need); + need -= drain; + if (e.getValue() - drain <= 0) { + it.remove(); + } else { + e.setValue(e.getValue() - drain); + } + } + } + return; + } + int cur = map.getOrDefault(this, 0); + if (cur - need <= 0) { + map.remove(this); + } else { + map.put(this, cur - need); + } + } + + public Material getType() { + return type; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof IngredientFingerprint that)) return false; + + // Specialized comparison for Divinity items + if (this.divinityItemId != null && that.divinityItemId != null) { + if (!this.divinityItemId.equals(that.divinityItemId)) return false; + // -1 on either side = wildcard (no level constraint in recipe) + if (this.divinityItemLevel == -1 || that.divinityItemLevel == -1) return true; + return this.divinityItemLevel == that.divinityItemLevel; + } + + // Fallback to original logic for non-Divinity items + if (this.hasSocketFill || that.hasSocketFill) return false; return customModelData == that.customModelData && unbreakable == that.unbreakable && durability == that.durability && @@ -95,6 +259,11 @@ public boolean equals(Object o) { @Override public int hashCode() { + if (divinityItemId != null) { + // Exclude level so that wildcard (-1) and specific levels hash to the same bucket. + // equals() handles the level comparison correctly. + return Objects.hash(divinityItemId); + } return Objects.hash(type, customModelData, displayName, lore, enchantments, unbreakable, durability); } } 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..e2e8527 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java +++ b/src/main/java/studio/magemonkey/fusion/gui/recipe/RecipeGuiEventRouter.java @@ -1,5 +1,6 @@ package studio.magemonkey.fusion.gui.recipe; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -12,8 +13,11 @@ import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.Inventory; +import studio.magemonkey.fusion.Fusion; +import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.data.player.FusionPlayer; import studio.magemonkey.fusion.data.player.PlayerLoader; +import studio.magemonkey.fusion.data.recipes.CraftingTable; import studio.magemonkey.fusion.gui.ProfessionGuiRegistry; import studio.magemonkey.fusion.gui.RecipeGui; @@ -75,8 +79,15 @@ public void onInventoryClose(InventoryCloseEvent event) { RecipeGui gui = findGuiFor(p, inv); if (gui == null) return; - // If the player closes this GUI, perform cleanup gui.close(p, inv); + + if (isPlayerClose(event)) { + CraftingTable table = gui.getTable(); + if (table.getUseCategories() && !table.getCategories().isEmpty()) { + Bukkit.getScheduler().runTaskLater(Fusion.getInstance(), + () -> ProfessionsCfg.getGUI(gui.getName()).open(p), 1L); + } + } } @EventHandler(ignoreCancelled = true) @@ -118,4 +129,15 @@ public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { if (gui == null) return; gui.close(p, gui.getInventory()); } + + /** Paper-safe check: returns true if the close was initiated by the player (not plugin-triggered). + * Falls back to true on Spigot where getReason() doesn't exist. */ + private static boolean isPlayerClose(InventoryCloseEvent event) { + try { + Object reason = event.getClass().getMethod("getReason").invoke(event); + return reason != null && "PLAYER".equals(reason.toString()); + } catch (Exception ignored) { + return true; // Spigot: no getReason(), treat all closes as player-initiated + } + } } diff --git a/src/main/java/studio/magemonkey/fusion/gui/show/ShowRecipesGui.java b/src/main/java/studio/magemonkey/fusion/gui/show/ShowRecipesGui.java index 11e9cef..2f1369e 100644 --- a/src/main/java/studio/magemonkey/fusion/gui/show/ShowRecipesGui.java +++ b/src/main/java/studio/magemonkey/fusion/gui/show/ShowRecipesGui.java @@ -19,9 +19,11 @@ import studio.magemonkey.fusion.Fusion; import studio.magemonkey.fusion.cfg.ProfessionsCfg; import studio.magemonkey.fusion.cfg.ShowRecipesCfg; +import studio.magemonkey.fusion.data.professions.pattern.Category; 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.ProfessionGuiRegistry; import studio.magemonkey.fusion.gui.slot.Slot; import java.util.ArrayList; @@ -287,9 +289,16 @@ public void onClick(InventoryClickEvent event) { } if (recipeSlots.containsKey(slot)) { - Recipe recipe = recipeSlots.get(slot); - CraftingTable table = recipe.getTable(); - ProfessionsCfg.getGuiMap().get(table.getName()).open(player, table.getCategory(recipe.getCategory())); + Recipe recipe = recipeSlots.get(slot); + CraftingTable table = recipe.getTable(); + ProfessionGuiRegistry gui = ProfessionsCfg.getGuiMap().get(table.getName()); + if (gui == null) return; + Category category = table.getCategory(recipe.getCategory()); + if (category != null) { + gui.open(player, category); + } else { + gui.open(player); + } } } } diff --git a/src/main/java/studio/magemonkey/fusion/util/StationChecker.java b/src/main/java/studio/magemonkey/fusion/util/StationChecker.java new file mode 100644 index 0000000..b9131db --- /dev/null +++ b/src/main/java/studio/magemonkey/fusion/util/StationChecker.java @@ -0,0 +1,90 @@ +package studio.magemonkey.fusion.util; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Sprawdza, czy gracz posiada wymaganą "stację" (Divinity lub vanilla) gdziekolwiek w swoim ekwipunku. + */ +public class StationChecker { + + private static final NamespacedKey DIVINITY_ITEM_ID = new NamespacedKey("divinity", "item_id"); + private static final String DIVINITY_PREFIX = "divinity:"; + + /** + * Główna metoda – zwraca true, jeśli gracz ma stację określoną przez parametr "station". + *

+ * Format parametru: + *

    + *
  • {@code "divinity:twoje_id"} – sprawdza Divinity item z danym item_id
  • + *
  • {@code "CRAFTING_TABLE"} – sprawdza dowolny przedmiot o tym materiale (vanilla)
  • + *
+ *

+ */ + public static boolean hasStation(Player player, String station) { + if (station == null || station.isEmpty()) return true; // brak wymagania + + // Rozróżnienie typu stacji po prefiksie + if (station.toLowerCase().startsWith(DIVINITY_PREFIX)) { + String divinityId = station.substring(DIVINITY_PREFIX.length()); + return hasDivinityStation(player, divinityId); + } else { + // Vanilla – oczekujemy nazwy materiału (np. CRAFTING_TABLE) + Material material = Material.getMaterial(station.toUpperCase()); + if (material == null) { + // Nieznany materiał – logujemy ostrzeżenie i uznajemy, że gracz nie ma stacji + player.getServer().getLogger().warning("[Fusion] Nieznany materiał stacji: " + station); + return false; + } + return hasVanillaStation(player, material); + } + } + + /** + * Sprawdza, czy gracz ma gdziekolwiek w ekwipunku przedmiot z podanym divinity:item_id. + */ + private static boolean hasDivinityStation(Player player, String itemId) { + return anyItemMatches(player, item -> { + ItemMeta meta = item.getItemMeta(); + if (meta == null) return false; + String id = meta.getPersistentDataContainer().get(DIVINITY_ITEM_ID, PersistentDataType.STRING); + return itemId.equalsIgnoreCase(id); + }); + } + + /** + * Sprawdza, czy gracz ma gdziekolwiek w ekwipunku przedmiot o podanym materiale. + */ + private static boolean hasVanillaStation(Player player, Material material) { + return anyItemMatches(player, item -> item.getType() == material); + } + + /** + * Pomocnicza – sprawdza wszystkie przedmioty gracza (cały Inventory + main hand) + * pod kątem warunku zdefiniowanego przez "predicate". + */ + private static boolean anyItemMatches(Player player, java.util.function.Predicate predicate) { + PlayerInventory inv = player.getInventory(); + + // Sprawdź wszystkie sloty Inventory (plecak, pancerz, offhand) + if (Arrays.stream(inv.getContents()).filter(Objects::nonNull).anyMatch(predicate)) { + return true; + } + + // Sprawdź przedmiot w głównej ręce (nie jest częścią getContents()) + ItemStack mainHand = inv.getItemInMainHand(); + if (mainHand != null && !mainHand.getType().isAir() && predicate.test(mainHand)) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/studio/magemonkey/fusion/util/TabCacher.java b/src/main/java/studio/magemonkey/fusion/util/TabCacher.java index 57c3a85..194a86c 100644 --- a/src/main/java/studio/magemonkey/fusion/util/TabCacher.java +++ b/src/main/java/studio/magemonkey/fusion/util/TabCacher.java @@ -12,9 +12,13 @@ import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemFlag; +import studio.magemonkey.divinity.Divinity; import studio.magemonkey.divinity.api.DivinityAPI; +import studio.magemonkey.divinity.modules.api.QModuleDrop; import studio.magemonkey.fabled.Fabled; import studio.magemonkey.fusion.cfg.ProfessionsCfg; +import studio.magemonkey.fusion.cfg.hooks.HookType; +import studio.magemonkey.fusion.Fusion; import java.util.*; @@ -46,10 +50,29 @@ public static void clearTabs(UUID uuid, String key) { PlayerTabs.get(uuid).CachedTabs.remove(key); } + public static void clearAllCaches(String key) { + PlayerTabs.values().forEach(cacher -> cacher.CachedTabs.remove(key)); + } + public static List getTabs(UUID uuid, String key, String arg) { List entries = new ArrayList<>(); if (isNotCached(uuid, key)) { switch (key) { + case "station": + for (Material material : Material.values()) { + if (material.isAir()) continue; + entries.add(material.toString().toLowerCase()); + } + if (Bukkit.getPluginManager().isPluginEnabled("Divinity")) { + DivinityAPI.getModuleManager() + .getCustomItemsManager() + .getItems() + .forEach((k) -> { + entries.add("DIVINITY_" + k.getId().toLowerCase()); + entries.add("DIV_CUSTOM_" + k.getId().toLowerCase()); + }); + } + break; case "items": for (Material material : Material.values()) { if (material.isAir()) continue; @@ -62,6 +85,62 @@ public static List getTabs(UUID uuid, String key, String arg) { .getItems() .forEach((k) -> entries.add("DIVINITY_" + k.getId().toLowerCase())); } + if (Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) { + String random = QModuleDrop.RANDOM_ID; + var mc = Divinity.getInstance().getModuleCache(); + for (String id : mc.getTierManager().getItemIds()) { + if (id.equals(random)) continue; + entries.add("DIV_ITEMGEN_" + id); + entries.add("DIV_ITEMGEN_" + id + "_NOENCH"); + } + for (String id : mc.getGemManager().getItemIds()) { + if (!id.equals(random)) entries.add("DIV_GEM_" + id); + } + for (String id : mc.getEssenceManager().getItemIds()) { + if (!id.equals(random)) entries.add("DIV_ESSENCE_" + id); + } + for (String id : mc.getRuneManager().getItemIds()) { + if (!id.equals(random)) entries.add("DIV_RUNE_" + id); + } + if (mc.getArrowManager() != null) + mc.getArrowManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_ARROW_" + id)); + if (mc.getConsumablesManager() != null) + mc.getConsumablesManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_CONSUMABLE_" + id)); + mc.getCustomItemsManager().getItems() + .forEach(k -> entries.add("DIV_CUSTOM_" + k.getId().toLowerCase())); + if (mc.getFortifyManager() != null) + mc.getFortifyManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_FORTIFY_" + id)); + if (mc.getIdentifyManager() != null) + mc.getIdentifyManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_IDENTIFY_" + id)); + if (mc.getMagicDustManager() != null) + mc.getMagicDustManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_DUST_" + id)); + if (mc.getRepairManager() != null) + mc.getRepairManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_REPAIR_" + id)); + if (mc.getResolveManager() != null) + mc.getResolveManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_DISMANTLE_" + id)); + if (mc.getRefineManager() != null) + mc.getRefineManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_REFINE_" + id)); + if (mc.getExtractManager() != null) + mc.getExtractManager().getItemIds().stream() + .filter(id -> !id.equals(random)) + .forEach(id -> entries.add("DIV_EXTRACTOR_" + id)); + } break; case "professions": entries.addAll(ProfessionsCfg.getMap().keySet()); @@ -231,4 +310,40 @@ public static List getFlagsTab(String[] args) { } return entries; } + + /** Level hints for DIV_ item tab completion (single value or range). */ + public static List getLevelHints(String partial) { + List hints = new ArrayList<>(); + hints.add(""); + hints.add("1"); + hints.add("5"); + hints.add("10"); + hints.add("20"); + hints.add("1:5"); + hints.add("1:10"); + List result = new ArrayList<>(); + for (String h : hints) { + if (h.toLowerCase().startsWith(partial.toLowerCase())) result.add(h); + } + return result; + } + + /** Material type suggestions for a given DIV_ITEMGEN item ID. */ + public static List getItemGenMaterialTypes(String itemId, String partial) { + List entries = new ArrayList<>(); + if (!Fusion.getInstance().getHookManager().isHooked(HookType.Divinity)) return entries; + var tierMgr = Divinity.getInstance().getModuleCache().getTierManager(); + if (tierMgr == null) return entries; + String cleanId = itemId.toUpperCase().endsWith("_NOENCH") + ? itemId.substring(0, itemId.length() - 7) + : itemId; + var genItem = tierMgr.getItemById(cleanId.toLowerCase()); + if (genItem == null) return entries; + entries.add(""); + for (studio.magemonkey.codex.api.items.ItemType mat : genItem.getMaterialsList()) { + String id = mat.getID().toLowerCase(); + if (id.toLowerCase().startsWith(partial.toLowerCase())) entries.add(id); + } + return entries; + } } diff --git a/src/main/resources/Editors/professions/RecipeEditor.yml b/src/main/resources/Editors/professions/RecipeEditor.yml index f0c5604..2804b0b 100644 --- a/src/main/resources/Editors/professions/RecipeEditor.yml +++ b/src/main/resources/Editors/professions/RecipeEditor.yml @@ -382,6 +382,47 @@ subEditor: auraManaAbility: '&3Aura Mana Ability &2$&7: &a$' auraSkill: '&3Aura Skill &2$&7: &a$' auraStats: '&3Aura Stat &2$&7: &a$' + station: + material: ANVIL + amount: 1 + durability: 0 + unbreakable: false + name: '&6Station' + lore: + - '&7Current: &a$' + - '&7The station (Divinity item ID) required' + - '&7to craft this recipe.' + - '&7Leave empty for no station requirement.' + - '&8--------------------' + - '&aLeft click &7to set the station ID.' + - '&aRight click &7to clear the station.' + flags: [ ] + enchants: { } + fuelCost: + material: BLAZE_POWDER + amount: 1 + durability: 0 + unbreakable: false + name: '&6Fuel Cost' + lore: + - '&7Current: &a$' + - '&7The amount of fuel consumed when' + - '&7crafting this recipe.' + - '&8--------------------' + - '&aLeft click &7to increase the fuel cost.' + - '&aRight click &7to decrease the fuel cost.' + - '&aShift click &7each to modify by 10.' + flags: [ ] + enchants: { } + highlight: + material: ORANGE_STAINED_GLASS_PANE + amount: 1 + durability: 0 + unbreakable: false + name: ' ' + lore: [ ] + flags: [ ] + enchants: { } back: material: BARRIER amount: 1 diff --git a/src/main/resources/lang/lang_en.yml b/src/main/resources/lang/lang_en.yml index dcf3716..8428a24 100644 --- a/src/main/resources/lang/lang_en.yml +++ b/src/main/resources/lang/lang_en.yml @@ -85,6 +85,8 @@ editor: recipeRenamed: "&aYou renamed the recipe &3$ &ato &3$" recipeAdded: "&aYou created the recipe &3$ &awith result &3$" recipePermissionUpdated: "&aYou updated the recipes permission &ato &3$" + recipeStationUpdated: "&aYou updated the recipe's station &ato &3$" + recipeFuelCostUpdated: "&aYou updated the recipe's fuel cost &ato &3$" resultEdited: "&aYou edited the recipe &3$ &awith result &3$" invalidConditionKey: "&cYou tried to parse an invalid condition key: &3$" invalidConditionValue: "&cYou tried to parse an invalid condition value: &3$" diff --git a/src/main/resources/professions/armor_smithing.yml b/src/main/resources/professions/armor_smithing.yml index f0d8977..a19574f 100644 --- a/src/main/resources/professions/armor_smithing.yml +++ b/src/main/resources/professions/armor_smithing.yml @@ -202,7 +202,17 @@ pattern: # Since here are no patterns, we can leave this empty too in theory categoryPattern: null # The categories that are used for the profession. -# Since we disabled categories, we can leave this empty +# Since we disabled categories, we can leave this empty. +# Example of the new category format with optional display name, lore, and slot: +#categories: +# - name: light_armor +# display: +# name: "&bLight Armor" +# lore: +# - "&7Leather and chainmail pieces" +# icon: LEATHER_CHESTPLATE +# order: 1 +# # slot: 0 # pins this category to result-slot index 0 (optional) categories: [] # The recipes that are shown in this profession @@ -318,3 +328,29 @@ recipes: enableLore: true lore: - 'These are chain boots' + + # ── Divinity item example ────────────────────────────────────────────────── + # Requires Divinity plugin. Replace "my_helmet" with an actual item ID from + # your Divinity ItemGenerator configuration. + # + # Ingredient format: DIV_ITEMGEN_:; + # DIV_ESSENCE_: | DIV_RUNE_: + # Result format: DIV_ITEMGEN_~level:: + # + # - name: DivinityHelmet + # craftingTime: 30 + # results: + # vanillaExp: 0 + # item: DIV_ITEMGEN_my_helmet~level:10:1 # result: 1x my_helmet at level 10 + # professionExp: 100 + # commands: [] + # costs: + # money: 500.0 + # exp: 0 + # items: + # - DIV_ITEMGEN_my_helmet:2;5 # ingredient: 2x my_helmet, minimum level 5 + # - DIV_ESSENCE_fire:1 # ingredient: 1x fire essence + # conditions: + # professionLevel: 20 + # mastery: false + # ────────────────────────────────────────────────────────────────────────── diff --git a/src/main/resources/professions/weapon_smithing.yml b/src/main/resources/professions/weapon_smithing.yml index 8e171de..4e445fb 100644 --- a/src/main/resources/professions/weapon_smithing.yml +++ b/src/main/resources/professions/weapon_smithing.yml @@ -203,14 +203,27 @@ categoryPattern: null categories: # Category for wooden weapons with first slot in order - name: wooden_weapons + # Optional display block: use 'display.name' to set a custom name shown in the GUI + # (falls back to 'name' if omitted). 'display.lore' adds lines below the icon. + display: + name: "&6Wooden Weapons" + lore: + - "&7Basic wooden armaments" icon: WOODEN_SWORD order: 1 + # Optional: 'slot' pins this category to a specific result-slot index (0-based). + # Omit or set to -1 for automatic sequential placement. + # slot: 0 # Category for stone weapons with second slot in order - name: stone_weapons + display: + name: "&7Stone Weapons" icon: STONE_SWORD order: 2 # Category for iron weapons with third slot in order - name: iron_weapons + display: + name: "&fIron Weapons" icon: IRON_SWORD order: 3 # This pattern is null now. You can however structure it 1:1 like the `pattern`-section @@ -328,3 +341,31 @@ recipes: professionLevel: 5 mastery: false + # ── Divinity item example ────────────────────────────────────────────────── + # Requires Divinity plugin. Replace "my_sword" with an actual item ID from + # your Divinity ItemGenerator configuration. + # + # Ingredient format: DIV_ITEMGEN_:; + # DIV_GEM_: | DIV_ESSENCE_: | DIV_RUNE_: + # Result format: DIV_ITEMGEN_~level:: + # Optional suffix _NOENCH prevents enchantments on match. + # + # - name: DivinityBlade + # craftingTime: 30 + # category: iron_weapons + # results: + # vanillaExp: 0 + # item: DIV_ITEMGEN_my_sword~level:10:1 # result: 1x my_sword at level 10 + # professionExp: 100 + # commands: [] + # costs: + # money: 500.0 + # exp: 0 + # items: + # - DIV_ITEMGEN_my_sword:2;5 # ingredient: 2x my_sword, minimum level 5 + # - DIV_GEM_ruby:1 # ingredient: 1x ruby gem + # conditions: + # professionLevel: 20 + # mastery: false + # ────────────────────────────────────────────────────────────────────────── +