From 61daf7311383486a79f2771ba66dbe0edcb05c59 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:35:08 +0200 Subject: [PATCH 1/5] fix: identify whole Cooking potatoes in Brewery cauldrons --- .../java/net/tfminecraft/cooking/Cooking.java | 4 + .../cooking/brewery/BreweryPotatoBridge.java | 70 +++++++ src/main/resources/plugin.yml | 2 +- .../brewery/api/events/IngedientAddEvent.java | 29 +++ .../brewery/BreweryPotatoBridgeTest.java | 195 ++++++++++++++++++ 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java create mode 100644 src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java create mode 100644 src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index 8933b99..a0d6d1d 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.BreweryPotatoBridge; import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.ModelLoader; import net.tfminecraft.cooking.loader.TrackLoader; @@ -298,6 +299,9 @@ private void releaseAnimalsCommand() { // Listeners // ---------------------------------------------------------------------- public void registerListeners() { + BreweryPotatoBridge breweryPotatoes = new BreweryPotatoBridge(this); + getServer().getPluginManager().registerEvents(breweryPotatoes, this); + breweryPotatoes.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/BreweryPotatoBridge.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java new file mode 100644 index 0000000..7a5c894 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java @@ -0,0 +1,70 @@ +package net.tfminecraft.cooking.brewery; + +import java.lang.reflect.Method; + +import org.bukkit.Material; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventException; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +import net.tfminecraft.cooking.item.FoodItem; + +/** Translates whole Cooking potatoes only inside Brewery's cauldron ingredient event. */ +public final class BreweryPotatoBridge implements Listener { + private final JavaPlugin plugin; + private boolean registered; + + public BreweryPotatoBridge(JavaPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + if ("BreweryX".equals(event.getPlugin().getName())) { + registerIfPresent(); + } + } + + public void registerIfPresent() { + Plugin brewery = plugin.getServer().getPluginManager().getPlugin("BreweryX"); + if (registered || brewery == null || !brewery.isEnabled()) return; + + try { + // Keep BreweryX optional, without bundling or linking its implementation into Cooking. + Class eventType = Class.forName( + "com.dre.brewery.api.events.IngedientAddEvent", true, + brewery.getClass().getClassLoader()).asSubclass(Event.class); + Method getIngredient = eventType.getMethod("getIngredient"); + Method setIngredient = eventType.getMethod("setIngredient", ItemStack.class); + plugin.getServer().getPluginManager().registerEvent(eventType, this, EventPriority.HIGH, + (listener, event) -> translate(event, getIngredient, setIngredient), plugin, true); + registered = true; + plugin.getLogger().info("BreweryX whole-potato ingredient bridge enabled."); + } catch (ReflectiveOperationException | LinkageError exception) { + plugin.getLogger().warning("BreweryX whole-potato bridge could not register: " + exception); + } + } + + private static void translate(Event event, Method getIngredient, Method setIngredient) throws EventException { + try { + FoodItem food = FoodItem.fromItem((ItemStack) getIngredient.invoke(event)); + if (food == null || !"vegetable_1".equals(food.getId()) + || !"Potato".equalsIgnoreCase(food.getOrigin())) return; + + // Brewery consumes the original hand item; only its internal ingredient copy changes. + // Freshness is deliberately unrestricted, so rotten whole potatoes also make vodka. + setIngredient.invoke(event, new ItemStack(Material.POTATO)); + } catch (ReflectiveOperationException exception) { + // Never consume a potato as an apple if the optional API changes or fails. + ((Cancellable) event).setCancelled(true); + throw new EventException(exception); + } + } +} 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/com/dre/brewery/api/events/IngedientAddEvent.java b/src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java new file mode 100644 index 0000000..1901b1a --- /dev/null +++ b/src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java @@ -0,0 +1,29 @@ +package com.dre.brewery.api.events; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.bukkit.inventory.ItemStack; + +/** Test-only contract for BreweryX 3.7.0's optional ingredient event. */ +public class IngedientAddEvent extends Event implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private ItemStack ingredient; + private boolean cancelled; + public boolean failRead; + public boolean failWrite; + + public IngedientAddEvent(ItemStack ingredient) { this.ingredient = ingredient; } + public ItemStack getIngredient() { + if (failRead) throw new IllegalStateException("read failed"); + return ingredient; + } + public void setIngredient(ItemStack ingredient) { + if (failWrite) throw new IllegalStateException("write failed"); + this.ingredient = ingredient; + } + @Override public boolean isCancelled() { return cancelled; } + @Override public void setCancelled(boolean cancelled) { this.cancelled = cancelled; } + @Override public HandlerList getHandlers() { return HANDLERS; } + public static HandlerList getHandlerList() { return HANDLERS; } +} diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java new file mode 100644 index 0000000..d59a3a7 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java @@ -0,0 +1,195 @@ +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.List; +import java.util.logging.Logger; + +import org.bukkit.Material; +import org.bukkit.event.EventException; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.EventExecutor; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +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.mockito.ArgumentCaptor; + +import com.dre.brewery.api.events.IngedientAddEvent; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.StationFoodTemplates; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.Keys; + +class BreweryPotatoBridgeTest { + private Cooking previousPlugin; + private List foods; + private List tracks; + private JavaPlugin owner; + private PluginManager manager; + private Plugin brewery; + private Logger logger; + private BreweryPotatoBridge bridge; + + @BeforeEach void setUp() { + MockBukkit.mock(); + previousPlugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods = new ArrayList<>(FoodLoader.oList); + tracks = new ArrayList<>(TrackLoader.oList); + FoodLoader.oList.clear(); + TrackLoader.oList.clear(); + TrackLoader.oList.add(StationFoodTemplates.freshness()); + for (String id : List.of("vegetable_1", "vegetable_cut", "vegetable_chopped", "soup", "apple")) { + FoodLoader.oList.add(StationFoodTemplates.template(id, "vegetable")); + } + owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); + manager = owner.getServer().getPluginManager(); + brewery = mock(Plugin.class); + when(brewery.getName()).thenReturn("BreweryX"); + when(brewery.isEnabled()).thenReturn(true); + when(manager.getPlugin("BreweryX")).thenReturn(brewery); + logger = mock(Logger.class); + when(owner.getLogger()).thenReturn(logger); + bridge = new BreweryPotatoBridge(owner); + } + + @AfterEach void tearDown() { + FoodLoader.oList.clear(); + FoodLoader.oList.addAll(foods); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); + Cooking.plugin = previousPlugin; + } + + @Test void translatesFreshStaleAndRottenWholePotatoesWithoutChangingTheOriginalStack() throws Exception { + EventExecutor executor = register(); + for (int age : List.of(0, 86400, StationFoodTemplates.ROTTEN)) { + ItemStack held = food("vegetable_1", "pOtAtO", age == 0 ? Material.POTATO : Material.APPLE, age); + ItemStack before = held.clone(); + IngedientAddEvent event = new IngedientAddEvent(held.clone()); + executor.execute(bridge, event); + assertEquals(new ItemStack(Material.POTATO), event.getIngredient()); + assertEquals(before, held, "quantity, model, quality and freshness must remain on the held item"); + assertFalse(event.isCancelled()); + } + } + + @Test void leavesVanillaApplesPotatoesOtherOriginsAndProcessedFoodsUntouched() throws Exception { + EventExecutor executor = register(); + List inputs = new ArrayList<>(List.of(new ItemStack(Material.APPLE), new ItemStack(Material.POTATO), + food("vegetable_1", "Carrot", Material.APPLE, StationFoodTemplates.ROTTEN), + food("vegetable_1", null, Material.APPLE, 0), + food("vegetable_cut", "Potato", Material.APPLE, 0), + food("vegetable_chopped", "Potato", Material.APPLE, 0), + food("soup", "Potato", Material.APPLE, 0), + food("apple", "Apple", Material.APPLE, 0))); + inputs.add(null); + for (ItemStack input : inputs) { + IngedientAddEvent event = new IngedientAddEvent(input); + executor.execute(bridge, event); + assertSame(input, event.getIngredient()); + } + } + + @Test void registersOnceAtHighPriorityAndRespectsOtherPluginsCancellations() { + register(); + bridge.registerIfPresent(); + bridge.onPluginEnable(new PluginEnableEvent(brewery)); + Plugin other = mock(Plugin.class); + when(other.getName()).thenReturn("ItemsAdder"); + bridge.onPluginEnable(new PluginEnableEvent(other)); + verify(manager, times(1)).registerEvent(eq(IngedientAddEvent.class), same(bridge), + eq(EventPriority.HIGH), any(), same(owner), eq(true)); + } + + @Test void actualEventDispatchSkipsCancelledAdditionsAndTranslatesAcceptedOnes() { + JavaPlugin realOwner = MockBukkit.createMockPlugin("PotatoBridgeTest"); + MockBukkit.createMockPlugin("BreweryX"); + BreweryPotatoBridge realBridge = new BreweryPotatoBridge(realOwner); + realBridge.registerIfPresent(); + ItemStack original = food("vegetable_1", "Potato", Material.APPLE, StationFoodTemplates.ROTTEN); + IngedientAddEvent cancelled = new IngedientAddEvent(original); + cancelled.setCancelled(true); + realOwner.getServer().getPluginManager().callEvent(cancelled); + assertSame(original, cancelled.getIngredient()); + assertTrue(cancelled.isCancelled()); + + IngedientAddEvent accepted = new IngedientAddEvent(original.clone()); + realOwner.getServer().getPluginManager().callEvent(accepted); + assertEquals(new ItemStack(Material.POTATO), accepted.getIngredient()); + assertEquals(10, original.getAmount()); + assertEquals(Material.APPLE, original.getType()); + } + + @Test void staysOptionalAndRegistersWhenBreweryEnablesLater() { + when(manager.getPlugin("BreweryX")).thenReturn(null); + bridge.registerIfPresent(); + when(manager.getPlugin("BreweryX")).thenReturn(brewery); + when(brewery.isEnabled()).thenReturn(false); + bridge.registerIfPresent(); + verify(manager, never()).registerEvent(any(), any(), any(), any(), any(), anyBoolean()); + when(brewery.isEnabled()).thenReturn(true); + bridge.onPluginEnable(new PluginEnableEvent(brewery)); + verify(manager).registerEvent(eq(IngedientAddEvent.class), same(bridge), any(), any(), same(owner), eq(true)); + } + + @Test void reportsRegistrationFailureAndCanRetry() { + doThrow(new NoClassDefFoundError("optional API")).doNothing().when(manager) + .registerEvent(any(), any(), any(), any(), any(), anyBoolean()); + bridge.registerIfPresent(); + verify(logger).warning(contains("whole-potato bridge could not register")); + bridge.registerIfPresent(); + verify(logger).info("BreweryX whole-potato ingredient bridge enabled."); + } + + @Test void cancelsInsteadOfRecordingAnAppleIfTheOptionalApiFails() { + EventExecutor executor = register(); + for (boolean readFailure : List.of(true, false)) { + IngedientAddEvent event = new IngedientAddEvent(food("vegetable_1", "Potato", Material.APPLE, 0)); + event.failRead = readFailure; + event.failWrite = !readFailure; + assertThrows(EventException.class, () -> executor.execute(bridge, event)); + assertTrue(event.isCancelled()); + } + } + + private EventExecutor register() { + bridge.registerIfPresent(); + ArgumentCaptor executor = ArgumentCaptor.forClass(EventExecutor.class); + verify(manager).registerEvent(eq(IngedientAddEvent.class), any(Listener.class), eq(EventPriority.HIGH), + executor.capture(), same(owner), eq(true)); + return executor.getValue(); + } + + private ItemStack food(String id, String origin, Material material, int age) { + ItemStack item = new ItemStack(material, 10); + var meta = item.getItemMeta(); + meta.setDisplayName("Rotten Potato"); // Names alone must never identify an ingredient. + meta.setCustomModelData(42007); + var data = meta.getPersistentDataContainer(); + data.set(Keys.FOOD_ID, PersistentDataType.STRING, id); + if (origin != null) data.set(Keys.ORIGIN, PersistentDataType.STRING, origin); + data.set(Keys.QUALITY, PersistentDataType.INTEGER, 4); + data.set(Keys.TAGS, PersistentDataType.STRING, "freshness." + age); + item.setItemMeta(meta); + return item; + } +} From c9a2197c64e967fd26b71766ccac59758c991360 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:05:28 +0200 Subject: [PATCH 2/5] fix: resolve Brewery ingredients from the Cooking catalog --- .github/dependencies.sha256 | 1 + .github/scripts/install-local-dependencies.sh | 4 + .github/scripts/prepare-release.sh | 1 + pom.xml | 6 + .../java/net/tfminecraft/cooking/Cooking.java | 9 +- .../cooking/brewery/BreweryBridge.java | 39 +++ .../brewery/BreweryIngredientListener.java | 77 ++++++ .../cooking/brewery/BreweryPotatoBridge.java | 70 ----- .../brewery/BrewingIngredientResolver.java | 49 ++++ .../brewery/CookingIngredientGate.java | 23 ++ .../brewery/api/events/IngedientAddEvent.java | 29 --- .../brewery/BreweryIngredientTest.java | 239 ++++++++++++++++++ .../brewery/BreweryPotatoBridgeTest.java | 195 -------------- .../BrewingIngredientResolverTest.java | 93 +++++++ 14 files changed, 537 insertions(+), 298 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java delete mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/CookingIngredientGate.java delete mode 100644 src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java create mode 100644 src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java delete mode 100644 src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java 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 a0d6d1d..a597bf6 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -14,7 +14,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.cooking.item.CookingPathHandler; -import net.tfminecraft.cooking.brewery.BreweryPotatoBridge; +import net.tfminecraft.cooking.brewery.BreweryBridge; import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.ModelLoader; import net.tfminecraft.cooking.loader.TrackLoader; @@ -98,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(); @@ -224,6 +225,7 @@ static void warnIfCharacterIdentityMissing(Logger logger) { @Override public void onDisable() { if (incompatibleCharacterApi) return; + breweryBridge.close(); SaturationGuard.stop(); NutritionDrainTask.stop(); HusbandryTickTask.stop(); @@ -299,9 +301,8 @@ private void releaseAnimalsCommand() { // Listeners // ---------------------------------------------------------------------- public void registerListeners() { - BreweryPotatoBridge breweryPotatoes = new BreweryPotatoBridge(this); - getServer().getPluginManager().registerEvents(breweryPotatoes, this); - breweryPotatoes.registerIfPresent(); + 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..0271243 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java @@ -0,0 +1,39 @@ +package net.tfminecraft.cooking.brewery; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +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(); + } + + 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..137c7b9 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java @@ -0,0 +1,77 @@ +package net.tfminecraft.cooking.brewery; + +import java.util.List; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; + +import com.dre.brewery.api.BreweryApi; +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 { + public static Runnable register(JavaPlugin plugin) { + CookingIngredientGate gate = new CookingIngredientGate(); + BCauldronRecipe acceptance = new BCauldronRecipe("tfmc_cooking_identity_gate", "Cooking ingredient acceptance"); + acceptance.setIngredients(List.of(gate)); + acceptance.setSaveInData(false); + BreweryIngredientListener listener = new BreweryIngredientListener(); + plugin.getServer().getPluginManager().registerEvents(listener, plugin); + Runnable cleanup = () -> { + HandlerList.unregisterAll(listener); + BCauldronRecipe.getAllRecipes().removeIf(recipe -> recipe == acceptance); + BCauldronRecipe.acceptedCustom.removeIf(item -> item == gate); + }; + // Added cauldron recipes survive Brewery config reloads; this one never matches a completed brew. + try { + BreweryApi.addCauldronRecipe(acceptance, false); + } catch (RuntimeException | LinkageError exception) { + cleanup.run(); + throw exception; + } + return cleanup; + } + + @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/BreweryPotatoBridge.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java deleted file mode 100644 index 7a5c894..0000000 --- a/src/main/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridge.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.tfminecraft.cooking.brewery; - -import java.lang.reflect.Method; - -import org.bukkit.Material; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventException; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.server.PluginEnableEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.java.JavaPlugin; - -import net.tfminecraft.cooking.item.FoodItem; - -/** Translates whole Cooking potatoes only inside Brewery's cauldron ingredient event. */ -public final class BreweryPotatoBridge implements Listener { - private final JavaPlugin plugin; - private boolean registered; - - public BreweryPotatoBridge(JavaPlugin plugin) { - this.plugin = plugin; - } - - @EventHandler - public void onPluginEnable(PluginEnableEvent event) { - if ("BreweryX".equals(event.getPlugin().getName())) { - registerIfPresent(); - } - } - - public void registerIfPresent() { - Plugin brewery = plugin.getServer().getPluginManager().getPlugin("BreweryX"); - if (registered || brewery == null || !brewery.isEnabled()) return; - - try { - // Keep BreweryX optional, without bundling or linking its implementation into Cooking. - Class eventType = Class.forName( - "com.dre.brewery.api.events.IngedientAddEvent", true, - brewery.getClass().getClassLoader()).asSubclass(Event.class); - Method getIngredient = eventType.getMethod("getIngredient"); - Method setIngredient = eventType.getMethod("setIngredient", ItemStack.class); - plugin.getServer().getPluginManager().registerEvent(eventType, this, EventPriority.HIGH, - (listener, event) -> translate(event, getIngredient, setIngredient), plugin, true); - registered = true; - plugin.getLogger().info("BreweryX whole-potato ingredient bridge enabled."); - } catch (ReflectiveOperationException | LinkageError exception) { - plugin.getLogger().warning("BreweryX whole-potato bridge could not register: " + exception); - } - } - - private static void translate(Event event, Method getIngredient, Method setIngredient) throws EventException { - try { - FoodItem food = FoodItem.fromItem((ItemStack) getIngredient.invoke(event)); - if (food == null || !"vegetable_1".equals(food.getId()) - || !"Potato".equalsIgnoreCase(food.getOrigin())) return; - - // Brewery consumes the original hand item; only its internal ingredient copy changes. - // Freshness is deliberately unrestricted, so rotten whole potatoes also make vodka. - setIngredient.invoke(event, new ItemStack(Material.POTATO)); - } catch (ReflectiveOperationException exception) { - // Never consume a potato as an apple if the optional API changes or fails. - ((Cancellable) event).setCancelled(true); - throw new EventException(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..037c5b5 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java @@ -0,0 +1,49 @@ +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 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 never changes identity. Other preparation states must remain raw. + for (TagTrack track : food.getTagTracks()) { + if (track.getCurrentStep() == null || "freshness".equals(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/test/java/com/dre/brewery/api/events/IngedientAddEvent.java b/src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java deleted file mode 100644 index 1901b1a..0000000 --- a/src/test/java/com/dre/brewery/api/events/IngedientAddEvent.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.dre.brewery.api.events; - -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; -import org.bukkit.inventory.ItemStack; - -/** Test-only contract for BreweryX 3.7.0's optional ingredient event. */ -public class IngedientAddEvent extends Event implements Cancellable { - private static final HandlerList HANDLERS = new HandlerList(); - private ItemStack ingredient; - private boolean cancelled; - public boolean failRead; - public boolean failWrite; - - public IngedientAddEvent(ItemStack ingredient) { this.ingredient = ingredient; } - public ItemStack getIngredient() { - if (failRead) throw new IllegalStateException("read failed"); - return ingredient; - } - public void setIngredient(ItemStack ingredient) { - if (failWrite) throw new IllegalStateException("write failed"); - this.ingredient = ingredient; - } - @Override public boolean isCancelled() { return cancelled; } - @Override public void setCancelled(boolean cancelled) { this.cancelled = cancelled; } - @Override public HandlerList getHandlers() { return HANDLERS; } - public static HandlerList getHandlerList() { return HANDLERS; } -} 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..74e4852 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java @@ -0,0 +1,239 @@ +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.event.HandlerList; +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()); + assertFalse(BCauldronRecipe.getAddedRecipes().getFirst().isSaveInData()); + } + + @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 runtimeGateSurvivesAcceptedListRebuildAndIsRemovedOnClose() { + 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)); + BCauldronRecipe.acceptedCustom.clear(); + BCauldronRecipe.getAddedRecipes().forEach(BCauldronRecipe::updateAcceptedLists); + assertSame(gate, RecipeItem.getMatchingRecipeItem(item("wheat", "Wheat", Material.TROPICAL_FISH), false)); + cleanup.run();assertTrue(BCauldronRecipe.recipes.isEmpty());assertTrue(BCauldronRecipe.acceptedCustom.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."); + bridge.close();bridge.close(); + } + + @Test void failedGateRegistrationUnregistersItsListenerAndLeavesNoPartialRecipe() { + JavaPlugin owner = MockBukkit.createMockPlugin("FailedBreweryBridge"); + int count = BCauldronRecipe.recipes.size(); + try (var failedApi = mockStatic(BreweryApi.class)) { + failedApi.when(() -> BreweryApi.addCauldronRecipe(any(), eq(false))) + .thenThrow(new IllegalStateException("recipe registration failed")); + assertThrows(IllegalStateException.class, () -> BreweryIngredientListener.register(owner)); + } + assertTrue(HandlerList.getRegisteredListeners(owner).isEmpty()); + assertEquals(count, BCauldronRecipe.recipes.size()); + } + + 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/BreweryPotatoBridgeTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java deleted file mode 100644 index d59a3a7..0000000 --- a/src/test/java/net/tfminecraft/cooking/brewery/BreweryPotatoBridgeTest.java +++ /dev/null @@ -1,195 +0,0 @@ -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.List; -import java.util.logging.Logger; - -import org.bukkit.Material; -import org.bukkit.event.EventException; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.server.PluginEnableEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.plugin.EventExecutor; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; -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.mockito.ArgumentCaptor; - -import com.dre.brewery.api.events.IngedientAddEvent; - -import net.tfminecraft.cooking.Cooking; -import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.item.StationFoodTemplates; -import net.tfminecraft.cooking.item.tag.TagTrack; -import net.tfminecraft.cooking.loader.FoodLoader; -import net.tfminecraft.cooking.loader.TrackLoader; -import net.tfminecraft.cooking.utils.Keys; - -class BreweryPotatoBridgeTest { - private Cooking previousPlugin; - private List foods; - private List tracks; - private JavaPlugin owner; - private PluginManager manager; - private Plugin brewery; - private Logger logger; - private BreweryPotatoBridge bridge; - - @BeforeEach void setUp() { - MockBukkit.mock(); - previousPlugin = Cooking.plugin; - Cooking.plugin = mock(Cooking.class); - when(Cooking.plugin.getName()).thenReturn("Cooking"); - when(Cooking.plugin.namespace()).thenReturn("cooking"); - foods = new ArrayList<>(FoodLoader.oList); - tracks = new ArrayList<>(TrackLoader.oList); - FoodLoader.oList.clear(); - TrackLoader.oList.clear(); - TrackLoader.oList.add(StationFoodTemplates.freshness()); - for (String id : List.of("vegetable_1", "vegetable_cut", "vegetable_chopped", "soup", "apple")) { - FoodLoader.oList.add(StationFoodTemplates.template(id, "vegetable")); - } - owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); - manager = owner.getServer().getPluginManager(); - brewery = mock(Plugin.class); - when(brewery.getName()).thenReturn("BreweryX"); - when(brewery.isEnabled()).thenReturn(true); - when(manager.getPlugin("BreweryX")).thenReturn(brewery); - logger = mock(Logger.class); - when(owner.getLogger()).thenReturn(logger); - bridge = new BreweryPotatoBridge(owner); - } - - @AfterEach void tearDown() { - FoodLoader.oList.clear(); - FoodLoader.oList.addAll(foods); - TrackLoader.oList.clear(); - TrackLoader.oList.addAll(tracks); - MockBukkit.unmock(); - Cooking.plugin = previousPlugin; - } - - @Test void translatesFreshStaleAndRottenWholePotatoesWithoutChangingTheOriginalStack() throws Exception { - EventExecutor executor = register(); - for (int age : List.of(0, 86400, StationFoodTemplates.ROTTEN)) { - ItemStack held = food("vegetable_1", "pOtAtO", age == 0 ? Material.POTATO : Material.APPLE, age); - ItemStack before = held.clone(); - IngedientAddEvent event = new IngedientAddEvent(held.clone()); - executor.execute(bridge, event); - assertEquals(new ItemStack(Material.POTATO), event.getIngredient()); - assertEquals(before, held, "quantity, model, quality and freshness must remain on the held item"); - assertFalse(event.isCancelled()); - } - } - - @Test void leavesVanillaApplesPotatoesOtherOriginsAndProcessedFoodsUntouched() throws Exception { - EventExecutor executor = register(); - List inputs = new ArrayList<>(List.of(new ItemStack(Material.APPLE), new ItemStack(Material.POTATO), - food("vegetable_1", "Carrot", Material.APPLE, StationFoodTemplates.ROTTEN), - food("vegetable_1", null, Material.APPLE, 0), - food("vegetable_cut", "Potato", Material.APPLE, 0), - food("vegetable_chopped", "Potato", Material.APPLE, 0), - food("soup", "Potato", Material.APPLE, 0), - food("apple", "Apple", Material.APPLE, 0))); - inputs.add(null); - for (ItemStack input : inputs) { - IngedientAddEvent event = new IngedientAddEvent(input); - executor.execute(bridge, event); - assertSame(input, event.getIngredient()); - } - } - - @Test void registersOnceAtHighPriorityAndRespectsOtherPluginsCancellations() { - register(); - bridge.registerIfPresent(); - bridge.onPluginEnable(new PluginEnableEvent(brewery)); - Plugin other = mock(Plugin.class); - when(other.getName()).thenReturn("ItemsAdder"); - bridge.onPluginEnable(new PluginEnableEvent(other)); - verify(manager, times(1)).registerEvent(eq(IngedientAddEvent.class), same(bridge), - eq(EventPriority.HIGH), any(), same(owner), eq(true)); - } - - @Test void actualEventDispatchSkipsCancelledAdditionsAndTranslatesAcceptedOnes() { - JavaPlugin realOwner = MockBukkit.createMockPlugin("PotatoBridgeTest"); - MockBukkit.createMockPlugin("BreweryX"); - BreweryPotatoBridge realBridge = new BreweryPotatoBridge(realOwner); - realBridge.registerIfPresent(); - ItemStack original = food("vegetable_1", "Potato", Material.APPLE, StationFoodTemplates.ROTTEN); - IngedientAddEvent cancelled = new IngedientAddEvent(original); - cancelled.setCancelled(true); - realOwner.getServer().getPluginManager().callEvent(cancelled); - assertSame(original, cancelled.getIngredient()); - assertTrue(cancelled.isCancelled()); - - IngedientAddEvent accepted = new IngedientAddEvent(original.clone()); - realOwner.getServer().getPluginManager().callEvent(accepted); - assertEquals(new ItemStack(Material.POTATO), accepted.getIngredient()); - assertEquals(10, original.getAmount()); - assertEquals(Material.APPLE, original.getType()); - } - - @Test void staysOptionalAndRegistersWhenBreweryEnablesLater() { - when(manager.getPlugin("BreweryX")).thenReturn(null); - bridge.registerIfPresent(); - when(manager.getPlugin("BreweryX")).thenReturn(brewery); - when(brewery.isEnabled()).thenReturn(false); - bridge.registerIfPresent(); - verify(manager, never()).registerEvent(any(), any(), any(), any(), any(), anyBoolean()); - when(brewery.isEnabled()).thenReturn(true); - bridge.onPluginEnable(new PluginEnableEvent(brewery)); - verify(manager).registerEvent(eq(IngedientAddEvent.class), same(bridge), any(), any(), same(owner), eq(true)); - } - - @Test void reportsRegistrationFailureAndCanRetry() { - doThrow(new NoClassDefFoundError("optional API")).doNothing().when(manager) - .registerEvent(any(), any(), any(), any(), any(), anyBoolean()); - bridge.registerIfPresent(); - verify(logger).warning(contains("whole-potato bridge could not register")); - bridge.registerIfPresent(); - verify(logger).info("BreweryX whole-potato ingredient bridge enabled."); - } - - @Test void cancelsInsteadOfRecordingAnAppleIfTheOptionalApiFails() { - EventExecutor executor = register(); - for (boolean readFailure : List.of(true, false)) { - IngedientAddEvent event = new IngedientAddEvent(food("vegetable_1", "Potato", Material.APPLE, 0)); - event.failRead = readFailure; - event.failWrite = !readFailure; - assertThrows(EventException.class, () -> executor.execute(bridge, event)); - assertTrue(event.isCancelled()); - } - } - - private EventExecutor register() { - bridge.registerIfPresent(); - ArgumentCaptor executor = ArgumentCaptor.forClass(EventExecutor.class); - verify(manager).registerEvent(eq(IngedientAddEvent.class), any(Listener.class), eq(EventPriority.HIGH), - executor.capture(), same(owner), eq(true)); - return executor.getValue(); - } - - private ItemStack food(String id, String origin, Material material, int age) { - ItemStack item = new ItemStack(material, 10); - var meta = item.getItemMeta(); - meta.setDisplayName("Rotten Potato"); // Names alone must never identify an ingredient. - meta.setCustomModelData(42007); - var data = meta.getPersistentDataContainer(); - data.set(Keys.FOOD_ID, PersistentDataType.STRING, id); - if (origin != null) data.set(Keys.ORIGIN, PersistentDataType.STRING, origin); - data.set(Keys.QUALITY, PersistentDataType.INTEGER, 4); - data.set(Keys.TAGS, PersistentDataType.STRING, "freshness." + age); - 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..a7b5871 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java @@ -0,0 +1,93 @@ +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)); + 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; + } +} From 688ff3a23d117d85edbc76b647c43c8d8be7faa3 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:23:50 +0200 Subject: [PATCH 3/5] fix: keep the Brewery gate out of cauldron recipes Register the Cooking identity gate directly in Brewery's accepted-item list, which Brewery only appends to on reload. The previous fake cauldron recipe broke /brew simulate tab completion, because Brewery cannot describe custom RecipeItem types. Drop the hook when BreweryX disables. Co-Authored-By: Claude Opus 5.5 --- .../cooking/brewery/BreweryBridge.java | 6 +++ .../brewery/BreweryIngredientListener.java | 14 ++----- .../brewery/BreweryIngredientTest.java | 37 +++++++++++-------- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java index 0271243..6587d46 100644 --- a/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryBridge.java @@ -2,6 +2,7 @@ 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; @@ -18,6 +19,11 @@ 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; diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java index 137c7b9..61fe9fd 100644 --- a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java @@ -1,7 +1,5 @@ package net.tfminecraft.cooking.brewery; -import java.util.List; - import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; @@ -9,7 +7,6 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; -import com.dre.brewery.api.BreweryApi; import com.dre.brewery.api.events.IngedientAddEvent; import com.dre.brewery.recipe.BCauldronRecipe; import com.dre.brewery.recipe.RecipeItem; @@ -23,19 +20,16 @@ public final class BreweryIngredientListener implements Listener { public static Runnable register(JavaPlugin plugin) { CookingIngredientGate gate = new CookingIngredientGate(); - BCauldronRecipe acceptance = new BCauldronRecipe("tfmc_cooking_identity_gate", "Cooking ingredient acceptance"); - acceptance.setIngredients(List.of(gate)); - acceptance.setSaveInData(false); BreweryIngredientListener listener = new BreweryIngredientListener(); - plugin.getServer().getPluginManager().registerEvents(listener, plugin); + // Brewery only appends to this list when it reloads, so the gate stays until cleanup. A cauldron + // recipe would also carry it, but would break /brew simulate, which cannot describe custom items. + BCauldronRecipe.acceptedCustom.add(gate); Runnable cleanup = () -> { HandlerList.unregisterAll(listener); - BCauldronRecipe.getAllRecipes().removeIf(recipe -> recipe == acceptance); BCauldronRecipe.acceptedCustom.removeIf(item -> item == gate); }; - // Added cauldron recipes survive Brewery config reloads; this one never matches a completed brew. try { - BreweryApi.addCauldronRecipe(acceptance, false); + plugin.getServer().getPluginManager().registerEvents(listener, plugin); } catch (RuntimeException | LinkageError exception) { cleanup.run(); throw exception; diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java index 74e4852..dcebe56 100644 --- a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java @@ -14,6 +14,7 @@ import org.bukkit.Material; 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; @@ -115,7 +116,7 @@ class BreweryIngredientTest { assertInstanceOf(SimpleItem.class, event.getRecipeItem()); assertEquals(before, wheat); assertFalse(event.isCancelled()); - assertFalse(BCauldronRecipe.getAddedRecipes().getFirst().isSaveInData()); + assertTrue(BCauldronRecipe.getAllRecipes().isEmpty()); } @Test void replacesOnlyTheRecordedIdentityForVanillaAndCustomWholeFoods() { @@ -183,7 +184,7 @@ class BreweryIngredientTest { assertTrue(failed.isCancelled()); assertEquals(10, failed.getIngredient().getAmount()); } - @Test void runtimeGateSurvivesAcceptedListRebuildAndIsRemovedOnClose() { + @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()); @@ -191,10 +192,15 @@ class BreweryIngredientTest { assertThrows(IllegalStateException.class, () -> gate.toIngredient(new ItemStack(Material.APPLE))); assertThrows(IllegalStateException.class, gate::toIngredientGeneric); assertFalse(gate.matches((ItemStack)null)); - BCauldronRecipe.acceptedCustom.clear(); - BCauldronRecipe.getAddedRecipes().forEach(BCauldronRecipe::updateAcceptedLists); + // 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)); - cleanup.run();assertTrue(BCauldronRecipe.recipes.isEmpty());assertTrue(BCauldronRecipe.acceptedCustom.isEmpty()); + BCauldronRecipe.recipes.clear(); + cleanup.run();assertTrue(BCauldronRecipe.acceptedCustom.isEmpty()); + assertTrue(HandlerList.getRegisteredListeners(server.getPluginManager().getPlugin("BreweryCookingTest")).isEmpty()); } @Test void optionalBridgeHandlesMissingDisabledLateAndRepeatedEnablesAndClosesCleanly() { @@ -211,19 +217,20 @@ class BreweryIngredientTest { 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 failedGateRegistrationUnregistersItsListenerAndLeavesNoPartialRecipe() { - JavaPlugin owner = MockBukkit.createMockPlugin("FailedBreweryBridge"); - int count = BCauldronRecipe.recipes.size(); - try (var failedApi = mockStatic(BreweryApi.class)) { - failedApi.when(() -> BreweryApi.addCauldronRecipe(any(), eq(false))) - .thenThrow(new IllegalStateException("recipe registration failed")); - assertThrows(IllegalStateException.class, () -> BreweryIngredientListener.register(owner)); - } - assertTrue(HandlerList.getRegisteredListeners(owner).isEmpty()); - assertEquals(count, BCauldronRecipe.recipes.size()); + @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) { From dad49cfbf3afe9ef8aa44e83070229f36240f98d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:28:11 +0200 Subject: [PATCH 4/5] fix: restore the Brewery gate before cauldron clicks Re-add the identity gate at LOWEST priority when a Cooking item is used on a block, so a plugin that rebuilds Brewery's accepted-item list does not silently drop Cooking ingredient resolution. Co-Authored-By: Claude Opus 5.5 --- .../brewery/BreweryIngredientListener.java | 23 +++++++++++++++---- .../brewery/BreweryIngredientTest.java | 14 +++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java index 61fe9fd..ceab2c7 100644 --- a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java @@ -4,6 +4,8 @@ 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; @@ -18,15 +20,14 @@ /** 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) { - CookingIngredientGate gate = new CookingIngredientGate(); BreweryIngredientListener listener = new BreweryIngredientListener(); - // Brewery only appends to this list when it reloads, so the gate stays until cleanup. A cauldron - // recipe would also carry it, but would break /brew simulate, which cannot describe custom items. - BCauldronRecipe.acceptedCustom.add(gate); + listener.ensureGate(); Runnable cleanup = () -> { HandlerList.unregisterAll(listener); - BCauldronRecipe.acceptedCustom.removeIf(item -> item == gate); + BCauldronRecipe.acceptedCustom.removeIf(item -> item == listener.gate); }; try { plugin.getServer().getPluginManager().registerEvents(listener, plugin); @@ -37,6 +38,18 @@ public static Runnable register(JavaPlugin plugin) { 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(); diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java index dcebe56..3d603d2 100644 --- a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java @@ -13,6 +13,9 @@ 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; @@ -199,6 +202,17 @@ class BreweryIngredientTest { 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()); } From 2b7424398e2bd6eac58b9dc055c0c4e973a501ad Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:50:14 +0200 Subject: [PATCH 5/5] fix: keep warm raw foods as Brewery ingredients Warmth is a temporary temperature state, so treat it like freshness when resolving a whole ingredient. Cooked foods are still rejected by the cooked track. Co-Authored-By: Claude Opus 5.5 --- .../cooking/brewery/BrewingIngredientResolver.java | 6 ++++-- .../cooking/brewery/BrewingIngredientResolverTest.java | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java b/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java index 037c5b5..b4b3570 100644 --- a/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java +++ b/src/main/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolver.java @@ -16,6 +16,8 @@ /** 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) { @@ -25,9 +27,9 @@ public static boolean isCookingItem(ItemStack item) { public static String sourceFor(FoodItem food) { if (food == null || food.getOrigin() == null || food.hasSauce() || !food.getIngredients().isEmpty()) return null; - // Freshness never changes identity. Other preparation states must remain raw. + // Freshness and warmth never change identity. Other preparation states must remain raw. for (TagTrack track : food.getTagTracks()) { - if (track.getCurrentStep() == null || "freshness".equals(track.getId())) continue; + if (track.getCurrentStep() == null || IDENTITY_NEUTRAL.contains(track.getId())) continue; if (!"cooked".equals(track.getId()) || !"raw".equals(track.getCurrentStep().getId())) return null; } diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java index a7b5871..28bcaa2 100644 --- a/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java +++ b/src/test/java/net/tfminecraft/cooking/brewery/BrewingIngredientResolverTest.java @@ -76,6 +76,9 @@ class BrewingIngredientResolverTest { 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");