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..bee9313 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;
@@ -154,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/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..77c638e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/mmoitems/CookingStationIngredient.java
@@ -0,0 +1,196 @@
+package net.tfminecraft.cooking.mmoitems;
+
+import java.util.HashMap;
+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.loader.TrackLoader;
+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 food is what its plain item becomes on pickup (a whole salmon, wheat with its freshness
+ * 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) {
+ 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 (food != null) {
+ Map pins = pinnedTracks();
+ applyPins(food, pins);
+ if (!forDisplay) {
+ ageOpenTracks(food, pins.keySet());
+ }
+ 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).
+ stack = placeholder();
+ }
+ stack.setAmount(forDisplay ? Math.max(1, Math.min(getAmount(), stack.getMaxStackSize())) : getAmount());
+ return stack;
+ }
+
+ private static void applyPins(FoodItem food, Map pins) {
+ for (Map.Entry pin : pins.entrySet()) {
+ 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))) {
+ track.forceSetValue((int) steps.get(steps.size() - 1).getRequiredValue());
+ }
+ }
+ }
+
+ /**
+ * 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();
+ 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.
+ }
+ }
+ }
+ 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..ce957fa
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredients.java
@@ -0,0 +1,52 @@
+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;
+
+/**
+ * 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=...}.");
+ }
+
+ 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
new file mode 100644
index 0000000..a355d05
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/mmoitems/MMOItemsSupport.java
@@ -0,0 +1,36 @@
+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. 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 {
+ step.run();
+ return true;
+ } catch (RuntimeException | LinkageError 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
new file mode 100644
index 0000000..99310e4
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/mmoitems/StationFood.java
@@ -0,0 +1,126 @@
+package net.tfminecraft.cooking.mmoitems;
+
+import java.util.ArrayList;
+import java.util.List;
+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;
+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 = plainFood(item, 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);
+ }
+
+ /** 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 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());
+ 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 && CookingPathHandler.matches(plainFood(source, key), identity)) {
+ return source;
+ }
+ }
+ return null;
+ }
+
+ /** {@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 path.substring(0, paren) + "(" + String.join(";", kept) + ")";
+ }
+
+ 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()) {
+ 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..118df9a
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/mmoitems/MMOItemsIngredientTest.java
@@ -0,0 +1,431 @@
+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.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;
+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;
+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.api.crafting.ingredient.IngredientType;
+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);
+ 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(), any(), eq(StationFood.PLAIN_QUALITY)));
+ assertEquals(Material.SALMON, base.getValue().getType());
+ 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(), any(), 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(), 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(), 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());
+ }
+ }
+
+ @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(), 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());
+ }
+
+ @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)"));
+ // 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());
+
+ // 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(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue());
+ ingredient("cooking{item=\"seafood(type=seafood_whole;origin=Salmon;tags=freshness.x)\"}")
+ .generateItemStack(null, false);
+ assertEquals(StationFoodTemplates.ROTTEN, food.getValue().getTagTrack("freshness").getValue());
+ }
+ }
+
+ @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() {
+ 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));
+
+ 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));
+ }
+
+ 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;
+ }
+}