diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index 3f44ab5..7acb40c 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -5,3 +5,4 @@ a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems- 81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar 93317bf08c9dae4a645644caa313b5436b19e65cc13abf9f3e0afb951bd6c1d2 libs/CustomCrops-3.6.56.jar 7d3083bd05998d8cafd7114ced74a6fa2ec1c4d36f75dbbaee9f20d499a7c3ad libs/CustomFishing-2.3.27.jar +d7156d1cd2425d56592976351a3cfcb8646b1429a687d33c32098066c6c1c1c6 libs/BreweryX-3.7.0.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index 4bdd002..ec3aaac 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -4,6 +4,10 @@ set -euo pipefail # Hash-qualified versions prevent different private JARs sharing a Maven cache key. sha256sum --check .github/dependencies.sha256 +mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="libs/BreweryX-3.7.0.jar" -DgroupId="local" -DartifactId="BreweryX" \ + -Dversion="3.7.0-tfmc-d7156d1cd242" -Dpackaging=jar -DgeneratePom=true "$@" + mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/gson-2.14.0.jar" -DgroupId="local" -DartifactId="gson" \ -Dversion="2.14.0-tfmc-2cbd119bf196" -Dpackaging=jar -DgeneratePom=true "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index 59b28bb..80f53ce 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -3,6 +3,7 @@ set -euo pipefail : "${GH_TOKEN:?Set DEPS_TOKEN with Contents read access to TF-Minecraft/ServerAssets}" ref=4b80431398e4ff35d703cad915b7ee4e5924a763 mkdir -p libs +curl --fail --location --silent --show-error --retry 3 "https://cdn.modrinth.com/data/gvXaGv1n/versions/Q1Iefaeb/BreweryX-3.7.0.jar" > "libs/BreweryX-3.7.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/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" diff --git a/pom.xml b/pom.xml index 835c402..de323be 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,12 @@ + + local + BreweryX + 3.7.0-tfmc-d7156d1cd242 + provided + com.googlecode.json-simple json-simple diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index 8933b99..a597bf6 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -14,6 +14,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.cooking.item.CookingPathHandler; +import net.tfminecraft.cooking.brewery.BreweryBridge; import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.ModelLoader; import net.tfminecraft.cooking.loader.TrackLoader; @@ -97,6 +98,7 @@ public class Cooking extends JavaPlugin { private boolean incompatibleCharacterApi; private final CommandManager commands = new CommandManager(); + private final BreweryBridge breweryBridge = new BreweryBridge(this); private final ModelLoader modelLoader = new ModelLoader(); private final FoodLoader foodLoader = new FoodLoader(); @@ -223,6 +225,7 @@ static void warnIfCharacterIdentityMissing(Logger logger) { @Override public void onDisable() { if (incompatibleCharacterApi) return; + breweryBridge.close(); SaturationGuard.stop(); NutritionDrainTask.stop(); HusbandryTickTask.stop(); @@ -298,6 +301,8 @@ private void releaseAnimalsCommand() { // Listeners // ---------------------------------------------------------------------- public void registerListeners() { + getServer().getPluginManager().registerEvents(breweryBridge, this); + breweryBridge.registerIfPresent(); getServer().getPluginManager().registerEvents(plateManager, this); getServer().getPluginManager().registerEvents(craftingManager, this); getServer().getPluginManager().registerEvents(cookingManager, this); diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java new file mode 100644 index 0000000..6587d46 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java @@ -0,0 +1,45 @@ +package net.tfminecraft.cooking.brewery; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +/** Loads the Brewery-linked classes only while the optional plugin is available. */ +public final class BreweryBridge implements Listener, AutoCloseable { + private final JavaPlugin plugin; + private Runnable cleanup; + + public BreweryBridge(JavaPlugin plugin) { this.plugin = plugin; } + + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + if ("BreweryX".equals(event.getPlugin().getName())) registerIfPresent(); + } + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if ("BreweryX".equals(event.getPlugin().getName())) close(); + } + + public void registerIfPresent() { + Plugin brewery = plugin.getServer().getPluginManager().getPlugin("BreweryX"); + if (cleanup != null || brewery == null || !brewery.isEnabled()) return; + try { + Class listener = Class.forName("net.tfminecraft.cooking.brewery.BreweryIngredientListener", + true, BreweryBridge.class.getClassLoader()); + cleanup = (Runnable) listener.getMethod("register", JavaPlugin.class).invoke(null, plugin); + plugin.getLogger().info("BreweryX Cooking ingredient resolver enabled."); + } catch (ReflectiveOperationException | LinkageError exception) { + plugin.getLogger().warning("BreweryX Cooking ingredient resolver could not register: " + exception); + } + } + + @Override public void close() { + if (cleanup == null) return; + cleanup.run(); + cleanup = null; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java new file mode 100644 index 0000000..ceab2c7 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java @@ -0,0 +1,84 @@ +package net.tfminecraft.cooking.brewery; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; + +import com.dre.brewery.api.events.IngedientAddEvent; +import com.dre.brewery.recipe.BCauldronRecipe; +import com.dre.brewery.recipe.RecipeItem; +import com.dre.brewery.recipe.SimpleItem; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.utils.ItemRef; +import net.tfminecraft.tlibs.TLibs; + +/** Replaces only Brewery's internal ingredient copy; Brewery consumes the original item normally. */ +public final class BreweryIngredientListener implements Listener { + private final CookingIngredientGate gate = new CookingIngredientGate(); + + public static Runnable register(JavaPlugin plugin) { + BreweryIngredientListener listener = new BreweryIngredientListener(); + listener.ensureGate(); + Runnable cleanup = () -> { + HandlerList.unregisterAll(listener); + BCauldronRecipe.acceptedCustom.removeIf(item -> item == listener.gate); + }; + try { + plugin.getServer().getPluginManager().registerEvents(listener, plugin); + } catch (RuntimeException | LinkageError exception) { + cleanup.run(); + throw exception; + } + return cleanup; + } + + // Brewery only appends to this list when it reloads. A cauldron recipe would also keep the gate there, + // but would break /brew simulate, which cannot describe custom items. + private void ensureGate() { + if (!BCauldronRecipe.acceptedCustom.contains(gate)) BCauldronRecipe.acceptedCustom.add(gate); + } + + /** Restores the gate before Brewery's click handler if anything rebuilt the accepted list. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onCauldronClick(PlayerInteractEvent event) { + if (event.getAction() == Action.RIGHT_CLICK_BLOCK && BrewingIngredientResolver.isCookingItem(event.getItem())) ensureGate(); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onIngredient(IngedientAddEvent event) { + ItemStack input = event.getIngredient(); + try { + if (!BrewingIngredientResolver.isCookingItem(input)) { + // Preserve explicit custom ingredient recipes, but never treat another custom item as its base material. + if (event.getRecipeItem() instanceof SimpleItem) { + String path = TLibs.getItemAPI().getChecker().getAsStringPath(input); + if (path.startsWith("ia.") || path.startsWith("m.")) event.setCancelled(true); + } + return; + } + String source = BrewingIngredientResolver.sourceFor(FoodItem.fromItem(input)); + ItemStack canonical = source == null ? null : ItemRef.resolve(source); + if (canonical == null || canonical.getType().isAir() || BrewingIngredientResolver.isCookingItem(canonical)) { + event.setCancelled(true); + return; + } + RecipeItem match = RecipeItem.getMatchingRecipeItem(canonical, false); + if (match == null || (!source.startsWith("v.") && match instanceof SimpleItem)) { + event.setCancelled(true); + return; + } + canonical = canonical.clone(); + canonical.setAmount(1); + event.setIngredient(canonical); + } catch (RuntimeException | LinkageError exception) { + event.setCancelled(true); + throw exception; + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java b/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java new file mode 100644 index 0000000..b4b3570 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java @@ -0,0 +1,51 @@ +package net.tfminecraft.cooking.brewery; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.Keys; + +/** Resolves whole ingredients back to their configured input, independently of their visual model. */ +public final class BrewingIngredientResolver { + private static final Set IDENTITY_NEUTRAL = Set.of("freshness", "warmth"); + + private BrewingIngredientResolver() {} + + public static boolean isCookingItem(ItemStack item) { + return item != null && item.hasItemMeta() && item.getItemMeta().getPersistentDataContainer() + .has(Keys.FOOD_ID, PersistentDataType.STRING); + } + + public static String sourceFor(FoodItem food) { + if (food == null || food.getOrigin() == null || food.hasSauce() || !food.getIngredients().isEmpty()) return null; + // Freshness and warmth never change identity. Other preparation states must remain raw. + for (TagTrack track : food.getTagTracks()) { + if (track.getCurrentStep() == null || IDENTITY_NEUTRAL.contains(track.getId())) continue; + if (!"cooked".equals(track.getId()) || !"raw".equals(track.getCurrentStep().getId())) return null; + } + + Set sources = new HashSet<>(); + for (Map.Entry entry : ConversionLoader.get().entrySet()) { + String source = entry.getKey(); + String specification = entry.getValue(); + int open = specification.indexOf('('); + if (open < 0 || !specification.endsWith(")")) continue; + Map fields = FoodParser.extractFields(specification.substring(open + 1, specification.length() - 1)); + if (food.getId().equalsIgnoreCase(fields.get("type")) + && food.getOrigin().equalsIgnoreCase(fields.get("origin"))) sources.add(source); + } + // Prefer the canonical vanilla name when several input variants describe the same food (e.g. eggs). + String vanilla = "v." + food.getOrigin().toLowerCase(Locale.ROOT).replace(' ', '_'); + if (sources.contains(vanilla)) return vanilla; + return sources.size() == 1 ? sources.iterator().next() : null; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/brewery/CookingIngredientGate.java b/src/main/java/net/tfminecraft/cooking/brewery/CookingIngredientGate.java new file mode 100644 index 0000000..bf17e37 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/CookingIngredientGate.java @@ -0,0 +1,23 @@ +package net.tfminecraft.cooking.brewery; + +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.dre.brewery.recipe.Ingredient; +import com.dre.brewery.recipe.RecipeItem; + +/** Lets metadata-bearing Cooking items reach the resolver before Brewery rejects their visual base. */ +final class CookingIngredientGate extends RecipeItem { + CookingIngredientGate() { setAmount(1); makeImmutable(); } + @Override public boolean matches(ItemStack item) { return BrewingIngredientResolver.isCookingItem(item); } + @Override public boolean matches(Ingredient ingredient) { return false; } + @Override public boolean hasMaterials() { return false; } + @Override public List getMaterials() { return List.of(); } + @Override public String getDebugID() { return "cooking:identity_gate"; } + @Override public Ingredient toIngredient(ItemStack item) { throw new IllegalStateException("Cooking ingredient must be resolved first"); } + @Override public Ingredient toIngredientGeneric() { throw new IllegalStateException("Cooking gate is not a brewing ingredient"); } + @Override public boolean equals(Object other) { return this == other; } + @Override public int hashCode() { return System.identityHashCode(this); } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e93bb1a..4a5d59f 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, MMOItems, CustomCrops, SimpleFactions, CustomFishing] +softdepend: [RPCharacters, MMOCore, MMOItems, CustomCrops, SimpleFactions, CustomFishing, BreweryX] commands: cooking: diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java new file mode 100644 index 0000000..3d603d2 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java @@ -0,0 +1,260 @@ +package net.tfminecraft.cooking.brewery; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.HandlerList; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; +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.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedStatic; + +import com.dre.brewery.BreweryPlugin; +import com.dre.brewery.api.BreweryApi; +import com.dre.brewery.api.events.IngedientAddEvent; +import com.dre.brewery.recipe.BCauldronRecipe; +import com.dre.brewery.recipe.Ingredient; +import com.dre.brewery.recipe.PluginItem; +import com.dre.brewery.recipe.RecipeItem; +import com.dre.brewery.recipe.SimpleItem; +import com.dre.brewery.utility.MinecraftVersion; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +class BreweryIngredientTest { + private ServerMock server; + private Cooking previousPlugin; + private List previousFoods; + private Map previousConversions; + private List recipes; + private List accepted; + private Set simple, materials; + private int configCount; + private MockedStatic brewery; + private MockedStatic tlibs; + private ItemAPI api; + private BreweryIngredientListener listener; + private Runnable cleanup; + + @BeforeEach void setup() { + server = MockBukkit.mock(); + previousPlugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + previousFoods = new ArrayList<>(FoodLoader.oList); + FoodLoader.oList.clear(); + for (String id : List.of("vegetable_1", "vegetable_2", "wheat", "fruit_1", "fruit_2", "egg", "vegetable_cut")) + FoodLoader.oList.add(BrewingIngredientResolverTest.food(id, null)); + previousConversions = ConversionLoader.conversions; + ConversionLoader.conversions = new HashMap<>(Map.of( + "v.potato", "vegetable(type=vegetable_1;origin=Potato)", + "v.wheat", "grain(type=wheat;origin=Wheat)", + "v.apple", "fruit(type=fruit_1;origin=Apple)", + "v.egg", "egg(type=egg;origin=Egg)", + "ia.tfmc_cooking:grape", "fruit(type=fruit_2;origin=Grape)", + "ia.tfmc_cooking:onion", "vegetable(type=vegetable_2;origin=Onion)")); + brewery = mockStatic(BreweryPlugin.class); + brewery.when(BreweryPlugin::getMCVersion).thenReturn(MinecraftVersion.V1_21_10); + recipes = BCauldronRecipe.recipes; accepted = BCauldronRecipe.acceptedCustom; + simple = BCauldronRecipe.acceptedSimple; materials = BCauldronRecipe.acceptedMaterials; + configCount = BCauldronRecipe.numConfigRecipes; + BCauldronRecipe.recipes = new ArrayList<>(); BCauldronRecipe.numConfigRecipes = 0; + BCauldronRecipe.acceptedCustom = new ArrayList<>(); + BCauldronRecipe.acceptedSimple = new HashSet<>(Set.of(Material.APPLE, Material.POTATO, Material.WHEAT, Material.EGG)); + BCauldronRecipe.acceptedMaterials = new HashSet<>(BCauldronRecipe.acceptedSimple); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + tlibs = mockStatic(TLibs.class); tlibs.when(TLibs::getItemAPI).thenReturn(api); + for (Material mat : List.of(Material.APPLE, Material.POTATO, Material.WHEAT, Material.EGG)) + when(api.getCreator().getItemFromPath("v." + mat.name().toLowerCase(java.util.Locale.ROOT))).thenReturn(new ItemStack(mat)); + when(api.getChecker().getAsStringPath(any())).thenAnswer(c -> "v." + ((ItemStack)c.getArgument(0)).getType().name()); + listener = new BreweryIngredientListener(); + cleanup = BreweryIngredientListener.register(MockBukkit.createMockPlugin("BreweryCookingTest")); + } + + @AfterEach void teardown() { + cleanup.run(); + BCauldronRecipe.recipes = recipes; BCauldronRecipe.numConfigRecipes = configCount; + BCauldronRecipe.acceptedCustom = accepted; BCauldronRecipe.acceptedSimple = simple; + BCauldronRecipe.acceptedMaterials = materials; + ConversionLoader.conversions = previousConversions; + FoodLoader.oList.clear(); FoodLoader.oList.addAll(previousFoods); + tlibs.close(); brewery.close(); MockBukkit.unmock(); Cooking.plugin = previousPlugin; + } + + @Test void gateAdmitsRottenWheatBeforeAcceptanceWithoutMakingPlainFishAnIngredient() { + ItemStack wheat = item("wheat", "Wheat", Material.TROPICAL_FISH); + assertNotNull(RecipeItem.getMatchingRecipeItem(wheat, false)); + assertNull(RecipeItem.getMatchingRecipeItem(new ItemStack(Material.TROPICAL_FISH), false)); + IngedientAddEvent event = event(wheat); + ItemStack before = wheat.clone(); + server.getPluginManager().callEvent(event); + assertEquals(new ItemStack(Material.WHEAT), event.getIngredient()); + assertInstanceOf(SimpleItem.class, event.getRecipeItem()); + assertEquals(before, wheat); + assertFalse(event.isCancelled()); + assertTrue(BCauldronRecipe.getAllRecipes().isEmpty()); + } + + @Test void replacesOnlyTheRecordedIdentityForVanillaAndCustomWholeFoods() { + for (String[] identity : List.of(new String[]{"vegetable_1", "Potato", "POTATO"}, + new String[]{"fruit_1", "Apple", "APPLE"}, new String[]{"egg", "Egg", "EGG"})) { + ItemStack held = item(identity[0], identity[1], Material.APPLE); + ItemStack before = held.clone(); + IngedientAddEvent event = event(held); + listener.onIngredient(event); + assertEquals(new ItemStack(Material.valueOf(identity[2])), event.getIngredient()); + assertEquals(before, held); + } + ItemStack grape = new ItemStack(Material.APPLE); var meta = grape.getItemMeta();meta.setDisplayName("Grape");grape.setItemMeta(meta); + when(api.getCreator().getItemFromPath("ia.tfmc_cooking:grape")).thenReturn(grape); + PluginItem explicit = mock(PluginItem.class); + when(explicit.matches(any(ItemStack.class))).thenAnswer(c -> grape.equals(c.getArgument(0))); + BCauldronRecipe.acceptedCustom.add(explicit); + IngedientAddEvent event = event(item("fruit_2", "Grape", Material.APPLE)); + listener.onIngredient(event); + assertEquals(grape, event.getIngredient()); + assertSame(explicit, event.getRecipeItem()); + assertEquals(1, grape.getAmount()); + } + + @Test void rejectsUnmappedPreparedMissingAndInvalidFoodWithoutConsumingIt() { + when(api.getCreator().getItemFromPath("ia.tfmc_cooking:onion")).thenReturn(new ItemStack(Material.APPLE)); + when(api.getCreator().getItemFromPath("ia.tfmc_cooking:grape")).thenReturn(new ItemStack(Material.STONE)); + for (ItemStack input : List.of(item("vegetable_2", "Onion", Material.APPLE), + item("vegetable_cut", "Potato", Material.APPLE), item("missing", "Potato", Material.APPLE), + item("fruit_2", "Grape", Material.APPLE))) { + IngedientAddEvent event = event(input); listener.onIngredient(event); + assertTrue(event.isCancelled()); assertSame(input, event.getIngredient()); assertEquals(10, input.getAmount()); + } + for (ItemStack invalidCanonical : List.of(new ItemStack(Material.AIR), item("wheat", "Wheat", Material.WHEAT))) { + when(api.getCreator().getItemFromPath("v.wheat")).thenReturn(invalidCanonical); + IngedientAddEvent event = event(item("wheat", "Wheat", Material.TROPICAL_FISH)); + listener.onIngredient(event); assertTrue(event.isCancelled()); + } + when(api.getCreator().getItemFromPath("v.wheat")).thenReturn(null); + IngedientAddEvent missing = event(item("wheat", "Wheat", Material.TROPICAL_FISH)); + listener.onIngredient(missing);assertTrue(missing.isCancelled()); + } + + @Test void preservesPlainAndExplicitCustomIngredientsButBlocksUnmappedCustomBaseFallback() { + for (String path : List.of("v.apple", "modeled.(type=apple)", "ia.other:onion", "m.food.ONION")) { + ItemStack input = new ItemStack(Material.APPLE, 3); + when(api.getChecker().getAsStringPath(input)).thenReturn(path); + IngedientAddEvent event = event(input); listener.onIngredient(event); + assertEquals(path.startsWith("ia.") || path.startsWith("m."), event.isCancelled(), path); + assertSame(input, event.getIngredient()); + } + RecipeItem explicit = mock(PluginItem.class); + ItemStack input = new ItemStack(Material.APPLE, 4); + IngedientAddEvent custom = new IngedientAddEvent(server.addPlayer(), null, null, input, explicit); + listener.onIngredient(custom); assertFalse(custom.isCancelled()); assertSame(input, custom.getIngredient()); + } + + @Test void cancellationsAndResolverFailuresNeverTakeTheOriginalItem() { + IngedientAddEvent cancelled = event(item("vegetable_1", "Potato", Material.APPLE)); + cancelled.setCancelled(true); ItemStack original = cancelled.getIngredient(); + server.getPluginManager().callEvent(cancelled); assertSame(original, cancelled.getIngredient()); + when(api.getCreator().getItemFromPath("v.wheat")).thenThrow(new IllegalStateException("catalog unavailable")); + IngedientAddEvent failed = event(item("wheat", "Wheat", Material.TROPICAL_FISH)); + assertThrows(IllegalStateException.class, () -> listener.onIngredient(failed)); + assertTrue(failed.isCancelled()); assertEquals(10, failed.getIngredient().getAmount()); + } + + @Test void runtimeGateSurvivesAcceptedListRebuildWithoutARecipeAndIsRemovedOnClose() { + RecipeItem gate = BCauldronRecipe.acceptedCustom.getFirst(); + assertFalse(gate.matches(mock(Ingredient.class))); assertFalse(gate.hasMaterials()); assertTrue(gate.getMaterials().isEmpty()); + assertEquals("cooking:identity_gate", gate.getDebugID()); assertEquals(System.identityHashCode(gate), gate.hashCode()); + assertEquals(gate, gate); assertNotEquals(gate, new CookingIngredientGate()); + assertThrows(IllegalStateException.class, () -> gate.toIngredient(new ItemStack(Material.APPLE))); + assertThrows(IllegalStateException.class, gate::toIngredientGeneric); + assertFalse(gate.matches((ItemStack)null)); + // Brewery reloads re-add every recipe's items without clearing the accepted list. + BCauldronRecipe reloaded = new BCauldronRecipe("reloaded", "Reloaded"); + reloaded.setIngredients(List.of(new SimpleItem(Material.WHEAT))); + BreweryApi.addCauldronRecipe(reloaded, false); + assertSame(gate, BCauldronRecipe.acceptedCustom.getFirst()); + assertSame(gate, RecipeItem.getMatchingRecipeItem(item("wheat", "Wheat", Material.TROPICAL_FISH), false)); + BCauldronRecipe.recipes.clear(); + // Another plugin clearing the list is repaired by the next Cooking item clicked on a block. + BCauldronRecipe.acceptedCustom.clear(); + var player = server.addPlayer(); + for (Action action : List.of(Action.LEFT_CLICK_BLOCK, Action.RIGHT_CLICK_AIR)) + server.getPluginManager().callEvent(new PlayerInteractEvent(player, action, item("wheat", "Wheat", Material.TROPICAL_FISH), null, BlockFace.UP)); + server.getPluginManager().callEvent(new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, new ItemStack(Material.WHEAT), null, BlockFace.UP)); + server.getPluginManager().callEvent(new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, null, null, BlockFace.UP)); + assertTrue(BCauldronRecipe.acceptedCustom.isEmpty()); + for (int i = 0; i < 2; i++) + server.getPluginManager().callEvent(new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, item("wheat", "Wheat", Material.TROPICAL_FISH), null, BlockFace.UP)); + assertEquals(List.of(gate), BCauldronRecipe.acceptedCustom); + cleanup.run();assertTrue(BCauldronRecipe.acceptedCustom.isEmpty()); + assertTrue(HandlerList.getRegisteredListeners(server.getPluginManager().getPlugin("BreweryCookingTest")).isEmpty()); + } + + @Test void optionalBridgeHandlesMissingDisabledLateAndRepeatedEnablesAndClosesCleanly() { + JavaPlugin owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); + Logger logger = mock(Logger.class);when(owner.getLogger()).thenReturn(logger); + var manager = owner.getServer().getPluginManager(); + BreweryBridge bridge = new BreweryBridge(owner);bridge.close(); + when(manager.getPlugin("BreweryX")).thenReturn(null);bridge.registerIfPresent(); + Plugin optional = mock(Plugin.class);when(optional.getName()).thenReturn("BreweryX"); + when(manager.getPlugin("BreweryX")).thenReturn(optional);bridge.registerIfPresent(); + when(optional.isEnabled()).thenReturn(true); + doThrow(new NoClassDefFoundError("test linkage")).doNothing().when(manager).registerEvents(any(), same(owner)); + bridge.registerIfPresent();verify(logger).warning(contains("could not register")); + bridge.onPluginEnable(new PluginEnableEvent(optional));bridge.registerIfPresent(); + Plugin other = mock(Plugin.class);when(other.getName()).thenReturn("Other");bridge.onPluginEnable(new PluginEnableEvent(other)); + verify(logger).info("BreweryX Cooking ingredient resolver enabled."); + int gates = BCauldronRecipe.acceptedCustom.size(); + bridge.onPluginDisable(new PluginDisableEvent(other));assertEquals(gates, BCauldronRecipe.acceptedCustom.size()); + bridge.onPluginDisable(new PluginDisableEvent(optional));assertEquals(gates - 1, BCauldronRecipe.acceptedCustom.size()); + bridge.registerIfPresent();verify(logger, times(2)).info("BreweryX Cooking ingredient resolver enabled."); + bridge.close();bridge.close(); + } + + @Test void failedListenerRegistrationRemovesTheGate() { + JavaPlugin owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); + var manager = owner.getServer().getPluginManager(); + doThrow(new IllegalStateException("listener registration failed")).when(manager).registerEvents(any(), same(owner)); + List before = new ArrayList<>(BCauldronRecipe.acceptedCustom); + assertThrows(IllegalStateException.class, () -> BreweryIngredientListener.register(owner)); + assertEquals(before, BCauldronRecipe.acceptedCustom); + } + + private IngedientAddEvent event(ItemStack item) { + return new IngedientAddEvent(server.addPlayer(), null, null, item, RecipeItem.getMatchingRecipeItem(item, false)); + } + private ItemStack item(String type, String origin, Material material) { + ItemStack item = new ItemStack(material, 10);var meta = item.getItemMeta(); + meta.setDisplayName("Rotten Potato");meta.setCustomModelData(42007); + var pdc = meta.getPersistentDataContainer(); + pdc.set(Keys.FOOD_ID, PersistentDataType.STRING, type);pdc.set(Keys.ORIGIN, PersistentDataType.STRING, origin); + pdc.set(Keys.QUALITY, PersistentDataType.INTEGER, 4);item.setItemMeta(meta);return item; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java new file mode 100644 index 0000000..28bcaa2 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java @@ -0,0 +1,96 @@ +package net.tfminecraft.cooking.brewery; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.StationFoodTemplates; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.utils.FoodParser; + +class BrewingIngredientResolverTest { + private Map previous; + @BeforeEach void setup() { + previous = ConversionLoader.conversions; + ConversionLoader.conversions = new HashMap<>(); + new ConversionLoader().load(new File("src/main/resources/conversions.yml")); + } + @AfterEach void cleanup() { ConversionLoader.conversions = previous; } + + @Test void reversesTheEntireConfiguredWholeIngredientCatalogAcrossFreshnessStates() { + for (var entry : ConversionLoader.get().entrySet()) { + String specification = entry.getValue(); + int open = specification.indexOf('('); + var fields = FoodParser.extractFields(specification.substring(open + 1, specification.length() - 1)); + FoodItem food = food(fields.get("type"), fields.get("origin")); + TagTrack freshness = StationFoodTemplates.freshness(); + food.addOrModifyTrack(freshness); + String expected = List.of("v.blue_egg", "v.brown_egg").contains(entry.getKey()) ? "v.egg" : entry.getKey(); + for (int age : List.of(0, 86400, 259200)) { + food.getTagTrack("freshness").setValue(age); + assertEquals(expected, BrewingIngredientResolver.sourceFor(food), entry.getKey() + " at " + age); + } + } + } + + @Test void rejectsPreparedFoodsAndUnknownOrAmbiguousIdentities() { + assertNull(BrewingIngredientResolver.sourceFor(null)); + assertNull(BrewingIngredientResolver.sourceFor(food("wheat", null))); + assertNull(BrewingIngredientResolver.sourceFor(food("soup", "Potato"))); + assertNull(BrewingIngredientResolver.sourceFor(food("vegetable_cut", "Potato"))); + assertNull(BrewingIngredientResolver.sourceFor(food("vegetable_chopped", "Potato"))); + FoodItem sauced = food("vegetable_1", "Potato"); + sauced.setSauce(food("sauce", "Tomato")); + assertNull(BrewingIngredientResolver.sourceFor(sauced)); + FoodItem composed = food("vegetable_1", "Potato"); composed.addIngredient("Beef"); + assertNull(BrewingIngredientResolver.sourceFor(composed)); + for (String preparation : List.of("cut", "chopped", "processed", "mashed", "salted")) { + FoodItem prepared = food("vegetable_1", "Potato"); + prepared.addOrModifyTrack(new TagTrack(preparation, false, + List.of(new TagStep(preparation, preparation, 0, 1, 1)))); + assertNull(BrewingIngredientResolver.sourceFor(prepared), preparation); + } + ConversionLoader.conversions.put("ia.other:wheat", "grain(type=wheat;origin=Wheat)"); + assertEquals("v.wheat", BrewingIngredientResolver.sourceFor(food("wheat", "Wheat"))); + ConversionLoader.conversions.remove("v.wheat"); + ConversionLoader.conversions.put("ia.another:wheat", "grain(type=wheat;origin=Wheat)"); + assertNull(BrewingIngredientResolver.sourceFor(food("wheat", "Wheat"))); + ConversionLoader.conversions.put("v.invalid", "invalid"); + ConversionLoader.conversions.put("v.unclosed", "invalid(type=wheat"); + assertNull(BrewingIngredientResolver.sourceFor(food("missing", "Missing"))); + } + + @Test void distinguishesRawAndFriedEggsAndUsesLiveConversionsAfterReload() { + FoodItem egg = food("egg", "Egg"); + TagTrack cooked = new TagTrack("cooked", false, List.of( + new TagStep("raw", "Raw", 0, 1, 1), new TagStep("cooked", "Cooked", 1, 1, 1))); + egg.addOrModifyTrack(cooked); + assertEquals("v.egg", BrewingIngredientResolver.sourceFor(egg)); + // A raw egg taken off the heat early is warm but still an egg; a warm cooked egg is not. + egg.addOrModifyTrack(new TagTrack("warmth", true, List.of(new TagStep("hot", "Hot", 0, 1, 1)))); + assertEquals("v.egg", BrewingIngredientResolver.sourceFor(egg)); + egg.getTagTrack("cooked").setValue(1); + assertNull(BrewingIngredientResolver.sourceFor(egg)); + FoodItem potato = food("vegetable_1", "Potato"); + potato.addOrModifyTrack(new TagTrack("empty", false, List.of())); + assertEquals("v.potato", BrewingIngredientResolver.sourceFor(potato)); + ConversionLoader.conversions.put("v.potato", "vegetable(type=another_type;origin=Potato)"); + assertNull(BrewingIngredientResolver.sourceFor(potato)); + assertEquals("v.potato", BrewingIngredientResolver.sourceFor(food("another_type", "Potato"))); + } + + static FoodItem food(String id, String origin) { + FoodItem food = StationFoodTemplates.template(id, "ingredient"); + food.setOrigin(origin); return food; + } +}