From d038eb98550328bfe841d900f074824ce9354e92 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:22:27 +0200 Subject: [PATCH 1/5] feat: let MMOItems crafting stations take Cooking food Cooking turns picked-up salmon, wheat, sugar, beef and other foods into named food items, and MMOItems' vanilla{} ingredient refuses any item with a display name. Animal Station treats and cat eggs therefore rejected food players had picked up. Register a cooking{item="",amount=N,display="..."} station ingredient from onLoad, before MMOItems reads its stations. It claims Cooking food and the plain items Cooking converts on pickup, matching them as the food they become, so a fresh catch and a picked-up one both count. item must name a food type. A cancelled queued craft refunds the least the line accepts (lowest quality, aged out), so cancelling never improves food. Co-Authored-By: Claude Opus 5.5 --- .github/dependencies.sha256 | 1 + .github/scripts/install-local-dependencies.sh | 3 + .github/scripts/prepare-release.sh | 1 + README.md | 1 + pom.xml | 6 + .../java/net/tfminecraft/cooking/Cooking.java | 6 + .../cooking/item/CookingPathHandler.java | 2 +- .../mmoitems/CookingStationIngredient.java | 152 +++++++++ .../CookingStationPlayerIngredient.java | 19 ++ .../cooking/mmoitems/MMOItemsIngredients.java | 30 ++ .../cooking/mmoitems/MMOItemsSupport.java | 27 ++ .../cooking/mmoitems/StationFood.java | 71 ++++ src/main/resources/plugin.yml | 2 +- .../cooking/item/StationFoodTemplates.java | 34 ++ .../mmoitems/MMOItemsIngredientTest.java | 321 ++++++++++++++++++ 15 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java create mode 100644 src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationPlayerIngredient.java create mode 100644 src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java create mode 100644 src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java create mode 100644 src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/StationFoodTemplates.java create mode 100644 src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index 4099099..3f44ab5 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -1,6 +1,7 @@ 2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f libs/gson-2.14.0.jar feca4db853371704338621c120acc5cc32300d8af635fa42414b6213301e216b libs/joml-1.10.9.jar 225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar +a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems-6.10.1-SNAPSHOT.jar 81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar 93317bf08c9dae4a645644caa313b5436b19e65cc13abf9f3e0afb951bd6c1d2 libs/CustomCrops-3.6.56.jar 7d3083bd05998d8cafd7114ced74a6fa2ec1c4d36f75dbbaee9f20d499a7c3ad libs/CustomFishing-2.3.27.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index b312775..4bdd002 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -13,6 +13,9 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1. mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/MythicLib-1.7.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MythicLib" \ -Dversion="1.7.1-SNAPSHOT-tfmc-225aa7f75d4e" -Dpackaging=jar -DgeneratePom=true "$@" +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/MMOItems-6.10.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOItems" \ + -Dversion="6.10.1-SNAPSHOT-tfmc-a37f7789fcdc" -Dpackaging=jar -DgeneratePom=true "$@" mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/MMOCore-1.13.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOCore" \ -Dversion="1.13.1-SNAPSHOT-tfmc-81d511d08309" -Dpackaging=jar -DgeneratePom=true "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index 30f9485..59b28bb 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -6,6 +6,7 @@ mkdir -p libs curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/2cbd119bf196/gson-2.14.0.jar?ref=$ref" > "libs/gson-2.14.0.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/feca4db85337/joml-1.10.9.jar?ref=$ref" > "libs/joml-1.10.9.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/a37f7789fcdc/MMOItems-6.10.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOItems-6.10.1-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/81d511d08309/MMOCore-1.13.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOCore-1.13.1-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/93317bf08c9d/CustomCrops-3.6.56.jar?ref=$ref" > "libs/CustomCrops-3.6.56.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/7d3083bd0599/CustomFishing-2.3.27.jar?ref=$ref" > "libs/CustomFishing-2.3.27.jar" diff --git a/README.md b/README.md index 87d7a7a..38963d7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **Farming and husbandry** — crop fertility and harvest quality sit alongside animal care, breeding, genetics, and produce collection. - **Fish and seafood** — turn supported catches into cooking ingredients while preserving catch size. - **Character nutrition** — meals feed a character's food reserve and influence diet quality, with variety rewarding a broader selection of ingredients. +- **MMOItems crafting stations** — the `cooking{item="seafood(type=seafood_whole;origin=Salmon)",amount=64,display="Salmon"}` ingredient takes Cooking food, plus the plain items Cooking converts on pickup (a fresh catch counts as the salmon it becomes). `item` is a `c.` path without the prefix and must name a food `type`; quality and tags filters work too. Cooking claims those items before MMOItems' `vanilla{}` type, so use `cooking{}` for salmon, wheat, sugar, beef and the other converted foods. Cancelling a queued craft refunds the least the line accepts: its lowest quality, aged out unless the line pins that tag. ## A complete food journey diff --git a/pom.xml b/pom.xml index 67894fa..4c4d1ad 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,12 @@ 1.7.1-SNAPSHOT-tfmc-225aa7f75d4e provided + + local + MMOItems + 6.10.1-SNAPSHOT-tfmc-a37f7789fcdc + provided + local MMOCore diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index aa3d627..c9a01d4 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -66,6 +66,7 @@ import net.tfminecraft.cooking.milling.MillingStoneHandler; import net.tfminecraft.cooking.mixing.MixingBowlHandler; import net.tfminecraft.cooking.heat.HeatPickupGuard; +import net.tfminecraft.cooking.mmoitems.MMOItemsSupport; import net.tfminecraft.cooking.nutrition.BowlEatHandler; import net.tfminecraft.cooking.nutrition.FoodConsumeListener; import net.tfminecraft.cooking.nutrition.FoodLevelChangeGuard; @@ -124,6 +125,11 @@ public class Cooking extends JavaPlugin { private final OvenHandler ovenHandler = new OvenHandler(ovenBurnManager); private final CraftingManager craftingManager = new CraftingManager(); + @Override + public void onLoad() { + MMOItemsSupport.registerIfPresent(getServer().getPluginManager(), getLogger()); + } + @Override public void onEnable() { plugin = this; diff --git a/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java b/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java index b492634..c3b5815 100644 --- a/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java +++ b/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java @@ -65,7 +65,7 @@ public boolean matches(ItemStack item, String fullPath) { return matches(FoodItem.fromItem(item), fullPath); } - static boolean matches(FoodItem food, String fullPath) { + public static boolean matches(FoodItem food, String fullPath) { if (food == null || fullPath == null) { return false; } diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java new file mode 100644 index 0000000..acee06c --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java @@ -0,0 +1,152 @@ +package net.tfminecraft.cooking.mmoitems; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import io.lumine.mythic.lib.api.MMOLineConfig; +import net.Indyuce.mmoitems.api.crafting.ingredient.Ingredient; +import net.Indyuce.mmoitems.api.player.RPGPlayer; +import net.tfminecraft.cooking.item.CookingPathHandler; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.AgeScale; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; + +/** + * {@code cooking{item="seafood(type=seafood_whole;origin=Salmon)",amount=64,display="Salmon"}}. + * {@code item} is a Cooking path without the {@code c.} prefix, matched like every other + * {@code c.} path. It must name a food type: MMOItems hands that food back when a queued craft + * is cancelled. + */ +public class CookingStationIngredient extends Ingredient { + private final String path; + private final Map fields; + private final String display; + + public CookingStationIngredient(MMOLineConfig config) { + super(MMOItemsIngredients.ID, config); + config.validate("item"); + path = CookingPathHandler.stripPrefix(config.getString("item")); + int paren = path.indexOf('('); + fields = paren < 0 || !path.endsWith(")") ? Map.of() + : FoodParser.extractFields(path.substring(paren + 1, path.length() - 1)); + String type = field("type"); + if (type == null) { + throw new IllegalArgumentException("cooking ingredient item=\"" + path + + "\" needs a food type, e.g. seafood(type=seafood_whole;origin=Salmon)"); + } + String origin = field("origin"); + display = config.getString("display", origin != null ? origin : words(type)); + } + + private String field(String key) { + for (Map.Entry entry : fields.entrySet()) { + if (entry.getKey().trim().equalsIgnoreCase(key) && !entry.getValue().isBlank()) { + return entry.getValue().trim(); + } + } + return null; + } + + private static String words(String id) { + StringBuilder out = new StringBuilder(); + for (String word : id.split("_+")) { + if (word.isEmpty()) { + continue; + } + if (out.length() > 0) { + out.append(' '); + } + out.append(word.substring(0, 1).toUpperCase(Locale.ROOT)).append(word.substring(1)); + } + return out.toString(); + } + + public String getPath() { + return path; + } + + public String getDisplayName() { + return display; + } + + @Override + public String formatDisplay(String format) { + return format.replace("#item#", display).replace("#amount#", String.valueOf(getAmount())); + } + + @Override + public boolean matches(CookingStationPlayerIngredient ingredient) { + return CookingPathHandler.matches(ingredient.getFood(), path); + } + + /** + * The preview shows the food fresh. A refund (forDisplay false) cannot know what was spent, + * so it is the least the line accepts: its lowest quality, aged out on every track the line + * leaves open. Cancelling never improves food. + */ + @Override + public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { + FoodParser.Result parsed = FoodParser.parse(path); + ItemStack stack = null; + if (parsed != null && parsed.template != null) { + FoodItem food = parsed.template; + if (!forDisplay) { + ageOpenTracks(food); + } + stack = ItemBuilder.buildSingleWithQuality(food, parsed.explicitQuality + ? food._parsedQualMin : StationFood.PLAIN_QUALITY); + } + if (stack == null) { + // Cooking does not know this food type (yet). + stack = placeholder(); + } + stack.setAmount(forDisplay ? Math.max(1, Math.min(getAmount(), stack.getMaxStackSize())) : getAmount()); + return stack; + } + + private void ageOpenTracks(FoodItem food) { + Set pinned = pinnedTracks(); + for (TagTrack track : food.getTagTracks()) { + List steps = track.getSteps(); + if (track.isAgeable() && !steps.isEmpty() && !pinned.contains(track.getId().toLowerCase(Locale.ROOT))) { + track.forceSetValue((int) steps.get(steps.size() - 1).getRequiredValue()); + } + } + } + + private Set pinnedTracks() { + Set pinned = new HashSet<>(); + String tags = field("tags"); + if (tags != null) { + for (String tag : tags.split("[,:]")) { + String id = tag.split("\\.", 2)[0].trim(); + pinned.add(AgeScale.migrateTrackId(id).toLowerCase(Locale.ROOT)); + } + } + return pinned; + } + + @SuppressWarnings("deprecation") + private ItemStack placeholder() { + ItemStack stack = new ItemStack(Material.PAPER); + ItemMeta meta = stack.getItemMeta(); + meta.setDisplayName(display); + stack.setItemMeta(meta); + return stack; + } + + @Override + public String toString() { + return "cooking{item=\"" + path + "\",amount=" + getAmount() + "}"; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationPlayerIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationPlayerIngredient.java new file mode 100644 index 0000000..a7e10e1 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationPlayerIngredient.java @@ -0,0 +1,19 @@ +package net.tfminecraft.cooking.mmoitems; + +import io.lumine.mythic.lib.api.item.NBTItem; +import net.Indyuce.mmoitems.api.crafting.ingredient.inventory.PlayerIngredient; +import net.tfminecraft.cooking.item.FoodItem; + +/** An inventory stack MMOItems sorted into the cooking ingredient type, with the food it counts as. */ +public class CookingStationPlayerIngredient extends PlayerIngredient { + private final FoodItem food; + + public CookingStationPlayerIngredient(NBTItem item) { + super(item); + food = StationFood.describe(item.getItem()); + } + + public FoodItem getFood() { + return food; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java new file mode 100644 index 0000000..5121720 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java @@ -0,0 +1,30 @@ +package net.tfminecraft.cooking.mmoitems; + +import java.util.logging.Logger; + +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.crafting.ConditionalDisplay; +import net.Indyuce.mmoitems.manager.CraftingManager; + +/** + * Registers the {@code cooking} crafting station ingredient. MMOItems checks newly registered + * types first, so Cooking food and the plain items Cooking converts are claimed here before the + * {@code vanilla} type, which only accepts unnamed items. Use {@code cooking{...}} for them. + */ +public final class MMOItemsIngredients { + public static final String ID = "cooking"; + + private MMOItemsIngredients() {} + + static void register(Logger logger) { + register(MMOItems.plugin.getCrafting(), logger); + } + + static void register(CraftingManager crafting, Logger logger) { + crafting.registerIngredient(ID, CookingStationIngredient::new, + new ConditionalDisplay("&a✔ &7#amount# #item#", "&c✖ &7#amount# #item#"), + nbt -> StationFood.describe(nbt.getItem()) != null, + CookingStationPlayerIngredient::new); + logger.info("Registered the MMOItems crafting station ingredient cooking{item=...}."); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java new file mode 100644 index 0000000..a62afc0 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java @@ -0,0 +1,27 @@ +package net.tfminecraft.cooking.mmoitems; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.plugin.PluginManager; + +/** + * Keeps MMOItems classes out of Cooking unless MMOItems is installed. Call from onLoad: MMOItems + * reads its crafting stations when it enables, and the ingredient type must exist by then. + */ +public final class MMOItemsSupport { + private MMOItemsSupport() {} + + public static boolean registerIfPresent(PluginManager plugins, Logger logger) { + if (plugins.getPlugin("MMOItems") == null) { + return false; + } + try { + MMOItemsIngredients.register(logger); + return true; + } catch (RuntimeException | LinkageError e) { + logger.log(Level.WARNING, "Could not register the MMOItems cooking ingredient", e); + return false; + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java new file mode 100644 index 0000000..bbbc248 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java @@ -0,0 +1,71 @@ +package net.tfminecraft.cooking.mmoitems; + +import java.util.Locale; +import java.util.Map; + +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.fishing.CustomFishingCatalog; +import net.tfminecraft.cooking.fishing.SeafoodWholeItems; +import net.tfminecraft.cooking.fishing.VanillaFishAdapter; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.tlibs.TLibs; + +/** + * The food an inventory item counts as at an MMOItems crafting station. + * Cooking food is itself. A plain vanilla item (TLibs path {@code v.}) counts as the + * food Cooking turns it into on pickup, so a fresh catch and a picked-up one match the same + * {@code cooking{...}} ingredient. + */ +public final class StationFood { + /** Plain items carry no Cooking quality; they count as the lowest. */ + static final int PLAIN_QUALITY = 1; + + private StationFood() {} + + public static FoodItem describe(ItemStack item) { + if (item == null || item.getType().isAir()) { + return null; + } + FoodItem food = FoodItem.fromItem(item); + if (food != null) { + return food; + } + String path = TLibs.getItemAPI().getChecker().getAsStringPath(item); + if (path == null || !path.toLowerCase(Locale.ROOT).startsWith("v.")) { + return null; + } + FoodItem converted = vanillaFish(item); + if (converted == null) { + converted = conversion(path); + } + if (converted != null) { + converted.setQualityRange(PLAIN_QUALITY, PLAIN_QUALITY); + } + return converted; + } + + private static FoodItem vanillaFish(ItemStack item) { + VanillaFishAdapter.Decision decision = VanillaFishAdapter.decide(item.getType().name(), false); + FoodItem template = FoodLoader.getByString(CustomFishingCatalog.WHOLE_TYPE); + if (!decision.replaces() || template == null) { + return null; + } + return SeafoodWholeItems.describe(template, decision.fish().origin(), decision.fish().cut(), + decision.fish().sizeCm(), PLAIN_QUALITY, null); + } + + /** Plain items only have a {@code v.} path, so compare it to the conversion keys directly. */ + private static FoodItem conversion(String path) { + for (Map.Entry entry : ConversionLoader.get().entrySet()) { + if (entry.getKey().equalsIgnoreCase(path)) { + FoodParser.Result parsed = FoodParser.parse(entry.getValue()); + return parsed == null ? null : parsed.template; + } + } + return null; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index a05eedb..e93bb1a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, InteractibleFurniture] -softdepend: [RPCharacters, MMOCore, CustomCrops, SimpleFactions, CustomFishing] +softdepend: [RPCharacters, MMOCore, MMOItems, CustomCrops, SimpleFactions, CustomFishing] commands: cooking: diff --git a/src/test/java/net/tfminecraft/cooking/item/StationFoodTemplates.java b/src/test/java/net/tfminecraft/cooking/item/StationFoodTemplates.java new file mode 100644 index 0000000..99683f2 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/StationFoodTemplates.java @@ -0,0 +1,34 @@ +package net.tfminecraft.cooking.item; + +import java.util.List; + +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; + +/** Builds bare food templates for tests outside this package. */ +public final class StationFoodTemplates { + public static final int ROTTEN = 259200; + + private StationFoodTemplates() {} + + public static FoodItem template(String id, String category) { + FoodItem food = new FoodItem(id, id, false); + food.setCategory(category); + return food; + } + + public static TagTrack freshness() { + return new TagTrack("freshness", true, List.of( + new TagStep("fresh", "Fresh", 0, 1, 1), + new TagStep("rotten", "Rotten", ROTTEN, 1, 1))); + } + + /** A template whose freshness ages and whose seasoning does not. */ + public static FoodItem withTracks(String id, String category) { + FoodItem food = template(id, category); + food.addOrModifyTrack(freshness()); + food.addOrModifyTrack(new TagTrack("salted", false, List.of(new TagStep("salted", "Salted", 1, 1, 1)))); + food.addOrModifyTrack(new TagTrack("warmth", true, List.of())); + return food; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java new file mode 100644 index 0000000..9f5c494 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java @@ -0,0 +1,321 @@ +package net.tfminecraft.cooking.mmoitems; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import io.lumine.mythic.lib.api.MMOLineConfig; +import io.lumine.mythic.lib.api.item.NBTItem; +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.crafting.ConditionalDisplay; +import net.Indyuce.mmoitems.api.crafting.ingredient.Ingredient; +import net.Indyuce.mmoitems.manager.CraftingManager; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.fishing.CustomFishingCatalog; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.StationFoodTemplates; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +class MMOItemsIngredientTest { + private MockedStatic tlibs; + private ItemAPI api; + private List foods; + private Map conversions; + private Cooking plugin; + private List tracks; + private final Logger logger = Logger.getLogger("MMOItemsIngredientTest"); + + @BeforeEach + void setUp() { + MockBukkit.mock(); + plugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().getAsStringPath(any())).thenAnswer(call -> { + ItemStack item = call.getArgument(0); + return item.getItemMeta() != null && item.getItemMeta().hasDisplayName() + ? "modeled.(type=" + item.getType().name().toLowerCase() + ")" + : "v." + item.getType().name().toLowerCase(); + }); + foods = new ArrayList<>(FoodLoader.oList); + FoodLoader.oList.clear(); + FoodLoader.oList.add(StationFoodTemplates.withTracks(CustomFishingCatalog.WHOLE_TYPE, "seafood")); + FoodLoader.oList.add(StationFoodTemplates.template("wheat", "grain")); + tracks = new ArrayList<>(TrackLoader.oList); + TrackLoader.oList.clear(); + TrackLoader.oList.add(StationFoodTemplates.freshness()); + conversions = new HashMap<>(ConversionLoader.conversions); + ConversionLoader.conversions.clear(); + ConversionLoader.conversions.put("V.WHEAT", "grain(type=wheat;origin=Wheat)"); + ConversionLoader.conversions.put("v.beef", "meat(type=missing;origin=Beef)"); + CustomFishingCatalog.load(new File("src/main/resources/custom-fishing.yml")); + } + + @AfterEach + void tearDown() { + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + ConversionLoader.conversions.clear(); + ConversionLoader.conversions.putAll(conversions); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + CustomFishingCatalog.load(new File("src/main/resources/custom-fishing.yml")); + tlibs.close(); + MockBukkit.unmock(); + Cooking.plugin = plugin; + } + + @Test + void plainItemsCountAsTheFoodCookingConvertsThemInto() { + FoodItem salmon = StationFood.describe(new ItemStack(Material.SALMON)); + assertEquals("seafood", salmon.getCategory()); + assertEquals(CustomFishingCatalog.WHOLE_TYPE, salmon.getId()); + assertEquals("Salmon", salmon.getOrigin()); + assertEquals(StationFood.PLAIN_QUALITY, salmon.getQualityMin()); + + FoodItem wheat = StationFood.describe(new ItemStack(Material.WHEAT)); + assertEquals("grain", wheat.getCategory()); + assertEquals("wheat", wheat.getId()); + assertEquals("Wheat", wheat.getOrigin()); + assertEquals(StationFood.PLAIN_QUALITY, wheat.getQualityMin()); + } + + @Test + void otherItemsAreNotStationFood() { + assertNull(StationFood.describe(null)); + assertNull(StationFood.describe(new ItemStack(Material.AIR))); + assertNull(StationFood.describe(new ItemStack(Material.BONE))); + // A conversion whose food type no longer exists converts nothing. + assertNull(StationFood.describe(new ItemStack(Material.BEEF))); + + ItemStack named = new ItemStack(Material.SALMON); + var meta = named.getItemMeta(); + meta.setDisplayName("Prize Salmon"); + named.setItemMeta(meta); + assertNull(StationFood.describe(named)); + + var checker = api.getChecker(); + doReturn(null).when(checker).getAsStringPath(any()); + assertNull(StationFood.describe(new ItemStack(Material.SALMON))); + } + + @Test + void wholeFishNeedsItsFoodType() { + FoodLoader.oList.removeIf(food -> food.getId().equals(CustomFishingCatalog.WHOLE_TYPE)); + assertNull(StationFood.describe(new ItemStack(Material.SALMON))); + } + + @Test + void cookingFoodIsItself() { + FoodItem roast = StationFoodTemplates.template("roast", "meat"); + ItemStack stack = new ItemStack(Material.TROPICAL_FISH); + try (MockedStatic codec = mockStatic(FoodItem.class, CALLS_REAL_METHODS)) { + codec.when(() -> FoodItem.fromItem(stack)).thenReturn(roast); + assertSame(roast, StationFood.describe(stack)); + } + } + + @Test + void ingredientReadsItsLineAndMatchesFood() { + CookingStationIngredient salmon = ingredient( + "cooking{item=\"c.seafood(type=seafood_whole;origin=Salmon)\",amount=64,display=\"Fresh Salmon\"}"); + assertEquals("seafood(type=seafood_whole;origin=Salmon)", salmon.getPath()); + assertEquals("Fresh Salmon", salmon.getDisplayName()); + assertEquals(64, salmon.getAmount()); + assertEquals("cooking", salmon.getPrefix()); + assertEquals("&a 64 Fresh Salmon", salmon.formatDisplay("&a #amount# #item#")); + assertEquals("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\",amount=64}", salmon.toString()); + + assertTrue(salmon.matches(player(new ItemStack(Material.SALMON)))); + assertFalse(salmon.matches(player(new ItemStack(Material.COD)))); + assertFalse(ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;quality=3)\"}") + .matches(player(new ItemStack(Material.SALMON)))); + } + + @Test + void displayDefaultsToTheOriginThenTheType() { + assertEquals("Tropical Fish", + ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Tropical Fish)\"}").getDisplayName()); + assertEquals("Meat Red Meat", ingredient("cooking{item=\"meat(type=meat_red_meat)\"}").getDisplayName()); + assertEquals("Hot Pot", ingredient("cooking{item=\"soup(type=_hot_pot)\"}").getDisplayName()); + assertEquals("Seafood Whole", + ingredient("cooking{item=\"seafood(type=seafood_whole;origin= )\"}").getDisplayName()); + assertEquals(1, ingredient("cooking{item=\"seafood(type=seafood_whole)\"}").getAmount()); + } + + @Test + void ingredientLineNeedsAFoodType() { + assertThrows(IllegalArgumentException.class, () -> ingredient("cooking{amount=2}")); + assertThrows(IllegalArgumentException.class, () -> ingredient("cooking{item=\"c.\"}")); + assertThrows(IllegalArgumentException.class, () -> ingredient("cooking{item=\"seafood\"}")); + assertThrows(IllegalArgumentException.class, () -> ingredient("cooking{item=\"seafood(origin=Salmon)\"}")); + assertThrows(IllegalArgumentException.class, () -> ingredient("cooking{item=\"seafood(type=)\"}")); + } + + @Test + void previewShowsTheFoodFreshAndFitsOneStack() { + ItemStack built = new ItemStack(Material.SALMON); + try (MockedStatic builder = mockStatic(ItemBuilder.class)) { + ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), anyInt())).thenReturn(built); + ItemStack preview = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\",amount=99}") + .generateItemStack(null, true); + assertSame(built, preview); + assertEquals(preview.getMaxStackSize(), preview.getAmount()); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(StationFood.PLAIN_QUALITY))); + assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + assertEquals("Salmon", food.getValue().getOrigin()); + } + } + + @Test + void refundIsTheLeastTheLineAccepts() { + try (MockedStatic builder = mockStatic(ItemBuilder.class)) { + ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), anyInt())) + .thenAnswer(call -> new ItemStack(Material.SALMON)); + + ItemStack refund = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\",amount=99}") + .generateItemStack(null, false); + assertEquals(99, refund.getAmount()); + assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue()); + assertEquals(0, food.getValue().getTagTrack("salted").getValue()); + assertEquals(0, food.getValue().getTagTrack("warmth").getValue()); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(StationFood.PLAIN_QUALITY))); + + ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;quality=3-5;tags=freshness.0)\"}") + .generateItemStack(null, false); + assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(3))); + } + } + + @Test + void unknownFoodFallsBackToANamedPlaceholder() { + ItemStack unknown = ingredient("cooking{item=\"seafood(type=kraken)\",amount=2,display=\"Kraken\"}") + .generateItemStack(null, false); + assertEquals(Material.PAPER, unknown.getType()); + assertEquals("Kraken", unknown.getItemMeta().getDisplayName()); + assertEquals(2, unknown.getAmount()); + + try (MockedStatic builder = mockStatic(ItemBuilder.class)) { + builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), anyInt())).thenReturn(null); + ItemStack unbuilt = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\"}") + .generateItemStack(null, true); + assertEquals(Material.PAPER, unbuilt.getType()); + assertEquals("Salmon", unbuilt.getItemMeta().getDisplayName()); + } + } + + @Test + void playerIngredientKeepsTheStackAndItsFood() { + ItemStack stack = new ItemStack(Material.SALMON, 5); + CookingStationPlayerIngredient ingredient = player(stack); + assertSame(stack, ingredient.getItem()); + assertEquals(5, ingredient.getAmount()); + assertEquals("Salmon", ingredient.getFood().getOrigin()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + void registersTheCookingTypeFirstInLine() { + CraftingManager crafting = mock(CraftingManager.class); + MMOItems previous = MMOItems.plugin; + MMOItems.plugin = mock(MMOItems.class); + try { + when(MMOItems.plugin.getCrafting()).thenReturn(crafting); + MMOItemsIngredients.register(logger); + } finally { + MMOItems.plugin = previous; + } + ArgumentCaptor reader = ArgumentCaptor.forClass(Function.class); + ArgumentCaptor claims = ArgumentCaptor.forClass(Predicate.class); + ArgumentCaptor players = ArgumentCaptor.forClass(Function.class); + verify(crafting).registerIngredient(eq("cooking"), reader.capture(), any(ConditionalDisplay.class), + claims.capture(), players.capture()); + + Object built = reader.getValue().apply(new MMOLineConfig("cooking{item=\"seafood(type=seafood_whole)\",amount=2}")); + assertInstanceOf(CookingStationIngredient.class, built); + assertEquals(2, ((Ingredient) built).getAmount()); + assertTrue(claims.getValue().test(nbt(new ItemStack(Material.SALMON)))); + assertFalse(claims.getValue().test(nbt(new ItemStack(Material.BONE)))); + assertInstanceOf(CookingStationPlayerIngredient.class, players.getValue().apply(nbt(new ItemStack(Material.WHEAT)))); + } + + @Test + void registersOnlyWhenMMOItemsIsInstalled() { + PluginManager plugins = mock(PluginManager.class); + try (MockedStatic registry = mockStatic(MMOItemsIngredients.class)) { + assertFalse(MMOItemsSupport.registerIfPresent(plugins, logger)); + registry.verify(() -> MMOItemsIngredients.register(any(Logger.class)), never()); + + when(plugins.getPlugin("MMOItems")).thenReturn(mock(Plugin.class)); + assertTrue(MMOItemsSupport.registerIfPresent(plugins, logger)); + registry.verify(() -> MMOItemsIngredients.register(logger)); + + registry.when(() -> MMOItemsIngredients.register(any(Logger.class))).thenThrow(new NoClassDefFoundError("x")); + assertFalse(MMOItemsSupport.registerIfPresent(plugins, logger)); + registry.when(() -> MMOItemsIngredients.register(any(Logger.class))).thenThrow(new IllegalStateException("x")); + assertFalse(MMOItemsSupport.registerIfPresent(plugins, logger)); + } + } + + private static CookingStationIngredient ingredient(String line) { + return new CookingStationIngredient(new MMOLineConfig(line)); + } + + private static CookingStationPlayerIngredient player(ItemStack stack) { + return new CookingStationPlayerIngredient(nbt(stack)); + } + + private static NBTItem nbt(ItemStack stack) { + NBTItem nbt = mock(NBTItem.class); + when(nbt.getItem()).thenReturn(stack); + return nbt; + } +} From d1c4b4bb811f7b3415ec48a0c3f656cf0f355be6 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:39:57 +0200 Subject: [PATCH 2/5] fix: claim ItemsAdder-modelled food and build refunds on their item Found on Dev: MMOItems registers its ItemsAdder type while it enables, in front of cooking, so red meat cuts (ItemsAdder model) were read as ItemsAdder items and Meat Meal refused them. Move cooking back in front on the first server tick, once every plugin has enabled. Refunds of foods without their own model (whole fish) came back as paper, because ItemBuilder needs a base item for them. Build them on the plain item that becomes that food (a salmon for a whole salmon). Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/cooking/Cooking.java | 1 + .../mmoitems/CookingStationIngredient.java | 4 +- .../cooking/mmoitems/MMOItemsIngredients.java | 22 +++++++ .../cooking/mmoitems/MMOItemsSupport.java | 17 +++-- .../cooking/mmoitems/StationFood.java | 29 +++++++++ .../mmoitems/MMOItemsIngredientTest.java | 65 +++++++++++++++++-- 6 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index c9a01d4..bee9313 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -160,6 +160,7 @@ public void onEnable() { HusbandryLifecycleListener.resumeLoadedWorlds(); HusbandryTickTask.start(); HusbandryLocator.scanUnloaded(); + MMOItemsSupport.claimFirstIfPresent(getServer().getPluginManager(), getLogger()); }); getCommand("cooking").setExecutor(commands); diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java index acee06c..9670e3c 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java @@ -103,8 +103,8 @@ public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { if (!forDisplay) { ageOpenTracks(food); } - stack = ItemBuilder.buildSingleWithQuality(food, parsed.explicitQuality - ? food._parsedQualMin : StationFood.PLAIN_QUALITY); + stack = ItemBuilder.buildSingleWithQuality(food, StationFood.plainSource(path), + parsed.explicitQuality ? food._parsedQualMin : StationFood.PLAIN_QUALITY); } if (stack == null) { // Cooking does not know this food type (yet). diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java index 5121720..ce957fa 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java @@ -1,9 +1,11 @@ package net.tfminecraft.cooking.mmoitems; +import java.util.List; import java.util.logging.Logger; import net.Indyuce.mmoitems.MMOItems; import net.Indyuce.mmoitems.api.crafting.ConditionalDisplay; +import net.Indyuce.mmoitems.api.crafting.ingredient.IngredientType; import net.Indyuce.mmoitems.manager.CraftingManager; /** @@ -27,4 +29,24 @@ static void register(CraftingManager crafting, Logger logger) { CookingStationPlayerIngredient::new); logger.info("Registered the MMOItems crafting station ingredient cooking{item=...}."); } + + static void claimFirst(Logger logger) { + claimFirst(MMOItems.plugin.getCrafting(), logger); + } + + /** + * MMOItems registers its ItemsAdder (and Nexo, Oraxen, MythicMobs) types while it enables, + * after onLoad, and each goes in front. Cooking food built on an ItemsAdder model would then + * be read as an ItemsAdder item, so put cooking back in front once everything has enabled. + */ + static void claimFirst(CraftingManager crafting, Logger logger) { + List> types = crafting.getIngredients(); + for (int i = 1; i < types.size(); i++) { + if (ID.equals(types.get(i).getId())) { + types.add(0, types.remove(i)); + logger.info("Moved the cooking station ingredient ahead of " + types.get(1).getId() + "."); + return; + } + } + } } diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java index a62afc0..a355d05 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java @@ -6,21 +6,30 @@ import org.bukkit.plugin.PluginManager; /** - * Keeps MMOItems classes out of Cooking unless MMOItems is installed. Call from onLoad: MMOItems - * reads its crafting stations when it enables, and the ingredient type must exist by then. + * Keeps MMOItems classes out of Cooking unless MMOItems is installed. Register from onLoad: + * MMOItems reads its crafting stations when it enables, and the ingredient type must exist by then. */ public final class MMOItemsSupport { private MMOItemsSupport() {} public static boolean registerIfPresent(PluginManager plugins, Logger logger) { + return run(plugins, logger, () -> MMOItemsIngredients.register(logger), "register"); + } + + /** Call once every plugin has enabled (the first server tick). */ + public static boolean claimFirstIfPresent(PluginManager plugins, Logger logger) { + return run(plugins, logger, () -> MMOItemsIngredients.claimFirst(logger), "order"); + } + + private static boolean run(PluginManager plugins, Logger logger, Runnable step, String what) { if (plugins.getPlugin("MMOItems") == null) { return false; } try { - MMOItemsIngredients.register(logger); + step.run(); return true; } catch (RuntimeException | LinkageError e) { - logger.log(Level.WARNING, "Could not register the MMOItems cooking ingredient", e); + logger.log(Level.WARNING, "Could not " + what + " the MMOItems cooking ingredient", e); return false; } } diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java index bbbc248..2ddf519 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java @@ -3,11 +3,14 @@ import java.util.Locale; import java.util.Map; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import net.tfminecraft.cooking.fishing.CustomFishingCatalog; import net.tfminecraft.cooking.fishing.SeafoodWholeItems; +import net.tfminecraft.cooking.fishing.VanillaFish; import net.tfminecraft.cooking.fishing.VanillaFishAdapter; +import net.tfminecraft.cooking.item.CookingPathHandler; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.loader.ConversionLoader; import net.tfminecraft.cooking.loader.FoodLoader; @@ -58,6 +61,32 @@ private static FoodItem vanillaFish(ItemStack item) { decision.fish().sizeCm(), PLAIN_QUALITY, null); } + /** + * The plain item that becomes food matching {@code path}, e.g. a salmon for a whole salmon. + * Foods without a model of their own (whole fish) are built on that item. Null when none. + */ + public static ItemStack plainSource(String path) { + for (VanillaFish fish : CustomFishingCatalog.vanillaFish()) { + ItemStack source = source(fish.material()); + if (source != null && CookingPathHandler.matches(vanillaFish(source), path)) { + return source; + } + } + for (Map.Entry entry : ConversionLoader.get().entrySet()) { + String key = entry.getKey().toLowerCase(Locale.ROOT); + ItemStack source = key.startsWith("v.") ? source(key.substring(2)) : null; + if (source != null && CookingPathHandler.matches(conversion(key), path)) { + return source; + } + } + return null; + } + + private static ItemStack source(String material) { + Material type = Material.matchMaterial(material); + return type == null || !type.isItem() || type.isAir() ? null : new ItemStack(type); + } + /** Plain items only have a {@code v.} path, so compare it to the conversion keys directly. */ private static FoodItem conversion(String path) { for (Map.Entry entry : ConversionLoader.get().entrySet()) { diff --git a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java index 9f5c494..9f79c5c 100644 --- a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java @@ -44,6 +44,7 @@ import net.Indyuce.mmoitems.MMOItems; import net.Indyuce.mmoitems.api.crafting.ConditionalDisplay; import net.Indyuce.mmoitems.api.crafting.ingredient.Ingredient; +import net.Indyuce.mmoitems.api.crafting.ingredient.IngredientType; import net.Indyuce.mmoitems.manager.CraftingManager; import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.fishing.CustomFishingCatalog; @@ -202,12 +203,14 @@ void previewShowsTheFoodFreshAndFitsOneStack() { ItemStack built = new ItemStack(Material.SALMON); try (MockedStatic builder = mockStatic(ItemBuilder.class)) { ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); - builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), anyInt())).thenReturn(built); + ArgumentCaptor base = ArgumentCaptor.forClass(ItemStack.class); + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), base.capture(), anyInt())).thenReturn(built); ItemStack preview = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\",amount=99}") .generateItemStack(null, true); assertSame(built, preview); assertEquals(preview.getMaxStackSize(), preview.getAmount()); - builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(StationFood.PLAIN_QUALITY))); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), any(), eq(StationFood.PLAIN_QUALITY))); + assertEquals(Material.SALMON, base.getValue().getType()); assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); assertEquals("Salmon", food.getValue().getOrigin()); } @@ -217,7 +220,7 @@ void previewShowsTheFoodFreshAndFitsOneStack() { void refundIsTheLeastTheLineAccepts() { try (MockedStatic builder = mockStatic(ItemBuilder.class)) { ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); - builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), anyInt())) + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), any(), anyInt())) .thenAnswer(call -> new ItemStack(Material.SALMON)); ItemStack refund = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\",amount=99}") @@ -226,12 +229,12 @@ void refundIsTheLeastTheLineAccepts() { assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue()); assertEquals(0, food.getValue().getTagTrack("salted").getValue()); assertEquals(0, food.getValue().getTagTrack("warmth").getValue()); - builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(StationFood.PLAIN_QUALITY))); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), any(), eq(StationFood.PLAIN_QUALITY))); ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;quality=3-5;tags=freshness.0)\"}") .generateItemStack(null, false); assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); - builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), eq(3))); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), any(), eq(3))); } } @@ -244,7 +247,7 @@ void unknownFoodFallsBackToANamedPlaceholder() { assertEquals(2, unknown.getAmount()); try (MockedStatic builder = mockStatic(ItemBuilder.class)) { - builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), anyInt())).thenReturn(null); + builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenReturn(null); ItemStack unbuilt = ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon)\"}") .generateItemStack(null, true); assertEquals(Material.PAPER, unbuilt.getType()); @@ -261,6 +264,39 @@ void playerIngredientKeepsTheStackAndItsFood() { assertEquals("Salmon", ingredient.getFood().getOrigin()); } + @Test + void plainSourceIsTheItemThatBecomesTheFood() { + ConversionLoader.conversions.put("ia.tfmc_cooking:tomato", "vegetable(type=wheat;origin=Tomato)"); + ConversionLoader.conversions.put("v.not_a_material", "grain(type=wheat;origin=Oats)"); + assertEquals(Material.SALMON, StationFood.plainSource("seafood(type=seafood_whole;origin=Salmon)").getType()); + assertEquals(Material.WHEAT, StationFood.plainSource("grain(type=wheat;origin=Wheat)").getType()); + assertNull(StationFood.plainSource("vegetable(type=wheat;origin=Tomato)")); + assertNull(StationFood.plainSource("grain(type=wheat;origin=Oats)")); + assertNull(StationFood.plainSource("meat(type=meat_red_meat;origin=Beef)")); + } + + @Test + void cookingMovesBackInFrontOfLaterTypes() { + List> types = new ArrayList<>(List.of(type("itemsadder"), type("mythicitem"), + type("cooking"), type("vanilla"))); + CraftingManager crafting = mock(CraftingManager.class); + when(crafting.getIngredients()).thenReturn(types); + MMOItems previous = MMOItems.plugin; + MMOItems.plugin = mock(MMOItems.class); + try { + when(MMOItems.plugin.getCrafting()).thenReturn(crafting); + MMOItemsIngredients.claimFirst(logger); + } finally { + MMOItems.plugin = previous; + } + assertEquals(List.of("cooking", "itemsadder", "mythicitem", "vanilla"), ids(types)); + MMOItemsIngredients.claimFirst(crafting, logger); + assertEquals(List.of("cooking", "itemsadder", "mythicitem", "vanilla"), ids(types)); + types.remove(0); + MMOItemsIngredients.claimFirst(crafting, logger); + assertEquals(List.of("itemsadder", "mythicitem", "vanilla"), ids(types)); + } + @SuppressWarnings({ "unchecked", "rawtypes" }) @Test void registersTheCookingTypeFirstInLine() { @@ -302,9 +338,26 @@ void registersOnlyWhenMMOItemsIsInstalled() { assertFalse(MMOItemsSupport.registerIfPresent(plugins, logger)); registry.when(() -> MMOItemsIngredients.register(any(Logger.class))).thenThrow(new IllegalStateException("x")); assertFalse(MMOItemsSupport.registerIfPresent(plugins, logger)); + + assertTrue(MMOItemsSupport.claimFirstIfPresent(plugins, logger)); + registry.verify(() -> MMOItemsIngredients.claimFirst(logger)); + registry.when(() -> MMOItemsIngredients.claimFirst(any(Logger.class))).thenThrow(new NoSuchMethodError("x")); + assertFalse(MMOItemsSupport.claimFirstIfPresent(plugins, logger)); + when(plugins.getPlugin("MMOItems")).thenReturn(null); + assertFalse(MMOItemsSupport.claimFirstIfPresent(plugins, logger)); } } + private static IngredientType type(String id) { + IngredientType type = mock(IngredientType.class); + when(type.getId()).thenReturn(id); + return type; + } + + private static List ids(List> types) { + return types.stream().map(IngredientType::getId).toList(); + } + private static CookingStationIngredient ingredient(String line) { return new CookingStationIngredient(new MMOLineConfig(line)); } From f982275e9d62c75cb8cadd9e5492b751d7cc4bdf Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:45 +0200 Subject: [PATCH 3/5] fix: refund the food the plain item becomes, aged out Found on Dev: a cancelled Biscuit Treat refunded wheat with no freshness track at all, because the refund was built from the bare wheat type and pickup conversions add tags=freshness.0. That wheat would never rot. Build refunds from what the plain item becomes on pickup, then age it out. A quality filter no longer hides the plain source, so whole fish lines with quality=... still build on their salmon. Co-Authored-By: Claude Opus 5.5 --- .../mmoitems/CookingStationIngredient.java | 17 ++++++++------- .../cooking/mmoitems/StationFood.java | 15 +++++++++++-- .../mmoitems/MMOItemsIngredientTest.java | 21 ++++++++++++++++++- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java index 9670e3c..4bcd761 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java @@ -90,21 +90,24 @@ public boolean matches(CookingStationPlayerIngredient ingredient) { } /** - * The preview shows the food fresh. A refund (forDisplay false) cannot know what was spent, - * so it is the least the line accepts: its lowest quality, aged out on every track the line - * leaves open. Cancelling never improves food. + * The food is what its plain item becomes on pickup (a whole salmon, wheat with its freshness + * track), else the food the path names. The preview shows it fresh. A refund (forDisplay + * false) cannot know what was spent, so it is the least the line accepts: its lowest quality, + * aged out on every track the line leaves open. Cancelling never improves food. */ @Override public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { FoodParser.Result parsed = FoodParser.parse(path); + ItemStack source = StationFood.plainSource(path); + FoodItem food = source != null ? StationFood.describe(source) : parsed == null ? null : parsed.template; ItemStack stack = null; - if (parsed != null && parsed.template != null) { - FoodItem food = parsed.template; + if (food != null) { if (!forDisplay) { ageOpenTracks(food); } - stack = ItemBuilder.buildSingleWithQuality(food, StationFood.plainSource(path), - parsed.explicitQuality ? food._parsedQualMin : StationFood.PLAIN_QUALITY); + boolean explicit = parsed != null && parsed.explicitQuality; + stack = ItemBuilder.buildSingleWithQuality(food, source, + explicit ? parsed.template._parsedQualMin : StationFood.PLAIN_QUALITY); } if (stack == null) { // Cooking does not know this food type (yet). diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java index 2ddf519..682dca4 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java @@ -64,24 +64,35 @@ private static FoodItem vanillaFish(ItemStack item) { /** * The plain item that becomes food matching {@code path}, e.g. a salmon for a whole salmon. * Foods without a model of their own (whole fish) are built on that item. Null when none. + * Quality is not the item's to decide, so a quality filter does not rule a source out. */ public static ItemStack plainSource(String path) { for (VanillaFish fish : CustomFishingCatalog.vanillaFish()) { ItemStack source = source(fish.material()); - if (source != null && CookingPathHandler.matches(vanillaFish(source), path)) { + if (source != null && matchesAtSomeQuality(vanillaFish(source), path)) { return source; } } for (Map.Entry entry : ConversionLoader.get().entrySet()) { String key = entry.getKey().toLowerCase(Locale.ROOT); ItemStack source = key.startsWith("v.") ? source(key.substring(2)) : null; - if (source != null && CookingPathHandler.matches(conversion(key), path)) { + if (source != null && matchesAtSomeQuality(conversion(key), path)) { return source; } } return null; } + private static boolean matchesAtSomeQuality(FoodItem food, String path) { + for (int quality = 1; food != null && quality <= 5; quality++) { + food.setQualityRange(quality, quality); + if (CookingPathHandler.matches(food, path)) { + return true; + } + } + return false; + } + private static ItemStack source(String material) { Material type = Material.matchMaterial(material); return type == null || !type.isItem() || type.isAir() ? null : new ItemStack(type); diff --git a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java index 9f79c5c..c4457f3 100644 --- a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doReturn; @@ -234,7 +235,25 @@ void refundIsTheLeastTheLineAccepts() { ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;quality=3-5;tags=freshness.0)\"}") .generateItemStack(null, false); assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); - builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), any(), eq(3))); + builder.verify(() -> ItemBuilder.buildSingleWithQuality(any(), argThat(base -> base != null + && base.getType() == Material.SALMON), eq(3))); + } + } + + @Test + void refundIsWhatThePlainItemBecomesAgedOut() { + // The wheat type has no tracks; the pickup conversion adds freshness. + ConversionLoader.conversions.put("V.WHEAT", "grain(type=wheat;origin=Wheat;tags=freshness.0)"); + try (MockedStatic builder = mockStatic(ItemBuilder.class)) { + ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); + ArgumentCaptor base = ArgumentCaptor.forClass(ItemStack.class); + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), base.capture(), anyInt())) + .thenAnswer(call -> new ItemStack(Material.WHEAT)); + ingredient("cooking{item=\"grain(type=wheat;origin=Wheat)\",amount=2}").generateItemStack(null, false); + assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue()); + assertEquals(Material.WHEAT, base.getValue().getType()); + ingredient("cooking{item=\"grain(type=wheat;origin=Wheat)\"}").generateItemStack(null, true); + assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); } } From bde7e67defb52f7b71cb99dd4018e87c814468b5 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:39:16 +0200 Subject: [PATCH 4/5] fix: pick refund bases by food identity and set pinned tags CodeRabbit review: a line pinning a tag value (tags=freshness.259200) ruled out the plain salmon as the whole-fish base, so the refund fell back to paper. Pick the plain source by category, type and origin only, then set the tag values the line pins (adding the track when the food has none). Check sources the way describe reads them (vanilla fish first), so another conversion of the same item cannot pick the wrong food. Co-Authored-By: Claude Opus 5.5 --- .../mmoitems/CookingStationIngredient.java | 63 +++++++++++++++---- .../cooking/mmoitems/StationFood.java | 45 ++++++++----- .../mmoitems/MMOItemsIngredientTest.java | 38 +++++++++++ 3 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java index 4bcd761..52ab897 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java @@ -1,6 +1,6 @@ package net.tfminecraft.cooking.mmoitems; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -18,6 +18,7 @@ import net.tfminecraft.cooking.item.tag.AgeScale; import net.tfminecraft.cooking.item.tag.TagStep; import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.ItemBuilder; @@ -91,9 +92,10 @@ public boolean matches(CookingStationPlayerIngredient ingredient) { /** * The food is what its plain item becomes on pickup (a whole salmon, wheat with its freshness - * track), else the food the path names. The preview shows it fresh. A refund (forDisplay - * false) cannot know what was spent, so it is the least the line accepts: its lowest quality, - * aged out on every track the line leaves open. Cancelling never improves food. + * track), else the food the path names, with the tag values the line pins. The preview shows + * it fresh. A refund (forDisplay false) cannot know what was spent, so it is the least the + * line accepts: its lowest quality, aged out on every track the line leaves open. Cancelling + * never improves food. */ @Override public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { @@ -102,8 +104,10 @@ public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { FoodItem food = source != null ? StationFood.describe(source) : parsed == null ? null : parsed.template; ItemStack stack = null; if (food != null) { + Map pins = pinnedTracks(); + applyPins(food, pins); if (!forDisplay) { - ageOpenTracks(food); + ageOpenTracks(food, pins.keySet()); } boolean explicit = parsed != null && parsed.explicitQuality; stack = ItemBuilder.buildSingleWithQuality(food, source, @@ -117,8 +121,35 @@ public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { return stack; } - private void ageOpenTracks(FoodItem food) { - Set pinned = pinnedTracks(); + private static void applyPins(FoodItem food, Map pins) { + for (Map.Entry pin : pins.entrySet()) { + if (pin.getValue() == null) { + continue; + } + TagTrack track = track(food, pin.getKey()); + if (track != null) { + track.forceSetValue(pin.getValue()); + continue; + } + TagTrack base = TrackLoader.getByString(pin.getKey()); + if (base != null) { + TagTrack added = new TagTrack(base); + added.forceSetValue(pin.getValue()); + food.addOrModifyTrack(added); + } + } + } + + private static TagTrack track(FoodItem food, String id) { + for (TagTrack track : food.getTagTracks()) { + if (track.getId().equalsIgnoreCase(id)) { + return track; + } + } + return null; + } + + private static void ageOpenTracks(FoodItem food, Set pinned) { for (TagTrack track : food.getTagTracks()) { List steps = track.getSteps(); if (track.isAgeable() && !steps.isEmpty() && !pinned.contains(track.getId().toLowerCase(Locale.ROOT))) { @@ -127,13 +158,23 @@ private void ageOpenTracks(FoodItem food) { } } - private Set pinnedTracks() { - Set pinned = new HashSet<>(); + /** Track id to pinned value (null when the line names the track without a value). */ + private Map pinnedTracks() { + Map pinned = new HashMap<>(); String tags = field("tags"); if (tags != null) { for (String tag : tags.split("[,:]")) { - String id = tag.split("\\.", 2)[0].trim(); - pinned.add(AgeScale.migrateTrackId(id).toLowerCase(Locale.ROOT)); + String[] parts = tag.split("\\.", 2); + String raw = parts[0].trim(); + Integer value = null; + if (parts.length == 2) { + try { + value = AgeScale.migrateTrackValue(raw, Integer.parseInt(parts[1].trim())); + } catch (NumberFormatException e) { + value = null; // not a number: the track is named, with no value to set + } + } + pinned.put(AgeScale.migrateTrackId(raw).toLowerCase(Locale.ROOT), value); } } return pinned; diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java index 682dca4..99310e4 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.mmoitems; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -41,10 +43,7 @@ public static FoodItem describe(ItemStack item) { if (path == null || !path.toLowerCase(Locale.ROOT).startsWith("v.")) { return null; } - FoodItem converted = vanillaFish(item); - if (converted == null) { - converted = conversion(path); - } + FoodItem converted = plainFood(item, path); if (converted != null) { converted.setQualityRange(PLAIN_QUALITY, PLAIN_QUALITY); } @@ -61,36 +60,52 @@ private static FoodItem vanillaFish(ItemStack item) { decision.fish().sizeCm(), PLAIN_QUALITY, null); } + /** What a plain item becomes on pickup: vanilla fish first, then the conversions. */ + private static FoodItem plainFood(ItemStack item, String path) { + FoodItem fish = vanillaFish(item); + return fish != null ? fish : conversion(path); + } + /** - * The plain item that becomes food matching {@code path}, e.g. a salmon for a whole salmon. - * Foods without a model of their own (whole fish) are built on that item. Null when none. - * Quality is not the item's to decide, so a quality filter does not rule a source out. + * The plain item that becomes the food {@code path} names, e.g. a salmon for a whole salmon. + * Foods without a model of their own (whole fish) are built on that item. Only the food's + * identity (category, type, origin) counts: quality and tag values are the line's to set. + * Null when no plain item becomes that food. */ public static ItemStack plainSource(String path) { + String identity = identity(path); for (VanillaFish fish : CustomFishingCatalog.vanillaFish()) { ItemStack source = source(fish.material()); - if (source != null && matchesAtSomeQuality(vanillaFish(source), path)) { + FoodItem food = source == null ? null : plainFood(source, "v." + fish.material()); + if (CookingPathHandler.matches(food, identity)) { return source; } } for (Map.Entry entry : ConversionLoader.get().entrySet()) { String key = entry.getKey().toLowerCase(Locale.ROOT); ItemStack source = key.startsWith("v.") ? source(key.substring(2)) : null; - if (source != null && matchesAtSomeQuality(conversion(key), path)) { + if (source != null && CookingPathHandler.matches(plainFood(source, key), identity)) { return source; } } return null; } - private static boolean matchesAtSomeQuality(FoodItem food, String path) { - for (int quality = 1; food != null && quality <= 5; quality++) { - food.setQualityRange(quality, quality); - if (CookingPathHandler.matches(food, path)) { - return true; + /** {@code path} with only its category, type and origin. */ + static String identity(String path) { + int paren = path.indexOf('('); + if (paren < 0 || !path.endsWith(")")) { + return path; + } + List kept = new ArrayList<>(); + String inside = path.substring(paren + 1, path.length() - 1); + for (Map.Entry field : FoodParser.extractFields(inside).entrySet()) { + String key = field.getKey().trim().toLowerCase(Locale.ROOT); + if (key.equals("type") || key.equals("origin")) { + kept.add(key + "=" + field.getValue().trim()); } } - return false; + return path.substring(0, paren) + "(" + String.join(";", kept) + ")"; } private static ItemStack source(String material) { diff --git a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java index c4457f3..e786e39 100644 --- a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -292,6 +293,43 @@ void plainSourceIsTheItemThatBecomesTheFood() { assertNull(StationFood.plainSource("vegetable(type=wheat;origin=Tomato)")); assertNull(StationFood.plainSource("grain(type=wheat;origin=Oats)")); assertNull(StationFood.plainSource("meat(type=meat_red_meat;origin=Beef)")); + // Quality and tag values are the line's to set, not the plain item's. + assertEquals(Material.SALMON, StationFood.plainSource( + "seafood(type=seafood_whole;origin=Salmon;quality=4;tags=freshness.259200)").getType()); + assertNotNull(StationFood.plainSource("seafood")); + // A salmon is read as a whole fish first, so it never stands in for another food. + ConversionLoader.conversions.put("v.salmon", "grain(type=wheat;origin=Salmonish)"); + assertNull(StationFood.plainSource("grain(type=wheat;origin=Salmonish)")); + } + + @Test + void refundSetsTheTagValuesTheLinePins() { + try (MockedStatic builder = mockStatic(ItemBuilder.class)) { + ArgumentCaptor food = ArgumentCaptor.forClass(FoodItem.class); + ArgumentCaptor base = ArgumentCaptor.forClass(ItemStack.class); + builder.when(() -> ItemBuilder.buildSingleWithQuality(food.capture(), base.capture(), anyInt())) + .thenAnswer(call -> new ItemStack(Material.SALMON)); + + ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness.7)\"}") + .generateItemStack(null, false); + assertEquals(7, food.getValue().getTagTrack("freshness").getValue()); + assertEquals(Material.SALMON, base.getValue().getType()); + + // Wheat's type has no tracks and this conversion adds none: the pin adds freshness. + ingredient("cooking{item=\"grain(type=wheat;origin=Wheat;tags=freshness.5:spiciness.2)\"}") + .generateItemStack(null, true); + assertEquals(5, food.getValue().getTagTrack("freshness").getValue()); + assertNull(food.getValue().getTagTrack("spiciness")); + assertEquals(Material.WHEAT, base.getValue().getType()); + + // A track named without a usable value is pinned as it is: not set, not aged. + ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness)\"}") + .generateItemStack(null, false); + assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness.x)\"}") + .generateItemStack(null, false); + assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + } } @Test From 0f530baa3be5ec68f4376ac64d2f3d1fb2af7bb1 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:41:22 +0200 Subject: [PATCH 5/5] fix: age tracks a recipe names without a value CodeRabbit review: matching ignores a tag without a usable value, so a line with tags=freshness accepts any freshness, yet the refund kept that track fresh. Only tags with a numeric value count as pins now; the rest age out. Co-Authored-By: Claude Opus 5.5 --- .../mmoitems/CookingStationIngredient.java | 24 +++++++++---------- .../mmoitems/MMOItemsIngredientTest.java | 6 ++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java index 52ab897..77c638e 100644 --- a/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java +++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java @@ -123,9 +123,6 @@ public ItemStack generateItemStack(RPGPlayer player, boolean forDisplay) { private static void applyPins(FoodItem food, Map pins) { for (Map.Entry pin : pins.entrySet()) { - if (pin.getValue() == null) { - continue; - } TagTrack track = track(food, pin.getKey()); if (track != null) { track.forceSetValue(pin.getValue()); @@ -158,23 +155,26 @@ private static void ageOpenTracks(FoodItem food, Set pinned) { } } - /** Track id to pinned value (null when the line names the track without a value). */ + /** + * Track id to the value the line requires. Matching ignores a tag without a usable value, so + * such a tag constrains nothing and its track still ages out on a refund. + */ private Map pinnedTracks() { Map pinned = new HashMap<>(); String tags = field("tags"); if (tags != null) { for (String tag : tags.split("[,:]")) { String[] parts = tag.split("\\.", 2); + if (parts.length < 2) { + continue; + } String raw = parts[0].trim(); - Integer value = null; - if (parts.length == 2) { - try { - value = AgeScale.migrateTrackValue(raw, Integer.parseInt(parts[1].trim())); - } catch (NumberFormatException e) { - value = null; // not a number: the track is named, with no value to set - } + try { + int value = AgeScale.migrateTrackValue(raw, Integer.parseInt(parts[1].trim())); + pinned.put(AgeScale.migrateTrackId(raw).toLowerCase(Locale.ROOT), value); + } catch (NumberFormatException e) { + // Not a number: the line accepts nothing for this tag, so there is no value to keep. } - pinned.put(AgeScale.migrateTrackId(raw).toLowerCase(Locale.ROOT), value); } } return pinned; diff --git a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java index e786e39..118df9a 100644 --- a/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java @@ -322,13 +322,13 @@ void refundSetsTheTagValuesTheLinePins() { assertNull(food.getValue().getTagTrack("spiciness")); assertEquals(Material.WHEAT, base.getValue().getType()); - // A track named without a usable value is pinned as it is: not set, not aged. + // Matching ignores a tag without a usable value, so it constrains nothing: still aged. ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness)\"}") .generateItemStack(null, false); - assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue()); ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness.x)\"}") .generateItemStack(null, false); - assertEquals(0, food.getValue().getTagTrack("freshness").getValue()); + assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue()); } }