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; + } +}