From c1b22abea7d6e89a3d0621627ed6af0c0fda205f Mon Sep 17 00:00:00 2001 From: Justinas Launikonis <95593194+JustinasLa@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:07:02 +0200 Subject: [PATCH] feat: pour BreweryX brews into the saucepan as its liquid A finished alcoholic brew now replaces the water or milk, so both add-in slots stay free for a wine reduction with salt and pepper. Brewery's quality (1-10, half stars) becomes the brew's 1-5 stars with half stars rounded up, the recipe name leads the sauce's name, the recipe colour tints it, and the sauce gets a new Reduced tag (+10% food and nutrition). Non-alcoholic brews are refused, the bottle comes back, and the brew survives a chunk reload. Co-Authored-By: Claude Opus 5.5 --- .../cooking/brewery/BrewLiquid.java | 32 ++++ .../cooking/brewery/BrewLiquidReader.java | 31 ++++ .../brewery/BreweryIngredientListener.java | 2 + .../cooking/cooking/SauceReference.java | 74 ++++++++- src/main/resources/tags.yml | 8 + .../cooking/brewery/BrewLiquidTest.java | 70 ++++++++ .../brewery/BreweryIngredientTest.java | 11 ++ .../cooking/SauceReferenceBrewTest.java | 152 ++++++++++++++++++ 8 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BrewLiquid.java create mode 100644 src/main/java/net/tfminecraft/cooking/brewery/BrewLiquidReader.java create mode 100644 src/test/java/net/tfminecraft/cooking/brewery/BrewLiquidTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceBrewTest.java diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquid.java b/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquid.java new file mode 100644 index 0000000..53a6d1e --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquid.java @@ -0,0 +1,32 @@ +package net.tfminecraft.cooking.brewery; + +import java.util.function.Function; +import java.util.regex.Pattern; + +import org.bukkit.inventory.ItemStack; + +/** A finished brew as the saucepan sees it. The reader is set only while BreweryX is enabled. */ +public record BrewLiquid(String name, int stars, String colour, boolean alcoholic) { + private static final Pattern FORMATTING = Pattern.compile("(?i)[&§](#[0-9a-f]{6}|[0-9a-fk-orx])"); + private static Function reader; + + public static void setReader(Function value) { + reader = value; + } + + /** The held brew, or null when the item is not a finished brew or BreweryX is not running. */ + public static BrewLiquid from(ItemStack item) { + Function current = reader; + return current == null || item == null ? null : current.apply(item); + } + + /** Brewery rates 1-10 and shows half stars. A half star rounds up, like Cooking's ingredient averages. */ + public static int starsFor(int breweryQuality) { + return Math.clamp((breweryQuality + 1) / 2, 1, 5); + } + + /** Recipe names such as "&6Golden Mead" keep their words but not their colour codes. */ + public static String plainName(String name) { + return name == null ? "" : FORMATTING.matcher(name).replaceAll("").strip(); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquidReader.java b/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquidReader.java new file mode 100644 index 0000000..3d7943b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/brewery/BrewLiquidReader.java @@ -0,0 +1,31 @@ +package net.tfminecraft.cooking.brewery; + +import org.bukkit.Color; +import org.bukkit.inventory.ItemStack; + +import com.dre.brewery.Brew; +import com.dre.brewery.api.BreweryApi; +import com.dre.brewery.recipe.BRecipe; +import com.dre.brewery.recipe.PotionColor; + +/** Reads BreweryX brews for the saucepan. Only loaded while BreweryX is enabled. */ +final class BrewLiquidReader { + private BrewLiquidReader() {} + + static BrewLiquid read(ItemStack item) { + Brew brew = BreweryApi.getBrew(item); + return brew == null ? null : describe(brew.getCurrentRecipe(), brew.getQuality()); + } + + /** A brew still fermenting has no recipe yet, so it is not a finished drink. */ + static BrewLiquid describe(BRecipe recipe, int quality) { + if (recipe == null) return null; + return new BrewLiquid(BrewLiquid.plainName(recipe.getName(quality)), BrewLiquid.starsFor(quality), + hex(recipe.getColor()), recipe.getAlcohol() > 0); + } + + private static String hex(PotionColor colour) { + Color rgb = colour == null ? null : colour.getColor(); + return rgb == null ? null : String.format("%06x", rgb.asRGB()); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java index ceab2c7..2fa5819 100644 --- a/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java +++ b/src/main/java/net/tfminecraft/cooking/brewery/BreweryIngredientListener.java @@ -28,6 +28,7 @@ public static Runnable register(JavaPlugin plugin) { Runnable cleanup = () -> { HandlerList.unregisterAll(listener); BCauldronRecipe.acceptedCustom.removeIf(item -> item == listener.gate); + BrewLiquid.setReader(null); }; try { plugin.getServer().getPluginManager().registerEvents(listener, plugin); @@ -35,6 +36,7 @@ public static Runnable register(JavaPlugin plugin) { cleanup.run(); throw exception; } + BrewLiquid.setReader(BrewLiquidReader::read); return cleanup; } diff --git a/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java b/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java index b3c40de..b0b6bc0 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java @@ -28,6 +28,7 @@ import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import net.tfminecraft.cooking.brewery.BrewLiquid; import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.cup.BucketItems; @@ -79,8 +80,10 @@ public class SauceReference extends CookingReference { private static final String VAR_COLOURS = "sauce.colours"; private static final String VAR_LIQUID = "sauce.liquid"; + private static final String VAR_BREW = "sauce.brew"; private FoodItem liquidSource; + private BrewLiquid brew; public SauceReference(Furniture f, Method m) { @@ -244,7 +247,8 @@ public void scoop(Player p, ItemStack ladle) { for (String c : colours) { - if (c.equalsIgnoreCase("ffffff")) { + // A white brew (vodka) is not milk. + if (brew == null && c.equalsIgnoreCase("ffffff")) { hasMilk = true; @@ -300,6 +304,13 @@ public void scoop(Player p, ItemStack ladle) { sauce.addOrModifyTrack(cookedTrack); + TagTrack reduced = brew == null ? null : TrackLoader.getByString("reduced"); + if (reduced != null) { + reduced = new TagTrack(reduced); + reduced.setValue(0); + sauce.addOrModifyTrack(reduced); + } + List inputs = new ArrayList<>(); @@ -372,6 +383,11 @@ public void interact(FurnitureInteractEvent e) { } + BrewLiquid pouredBrew = secondaries.containsKey("liquid") ? null : BrewLiquid.from(item); + if (pouredBrew != null) { + pourBrew(p, pouredBrew); + return; + } if (!secondaries.containsKey("liquid") && isWrongSaucepanWater(item)) { p.sendMessage("Use a cup of water to fill the saucepan."); @@ -439,6 +455,47 @@ public void interact(FurnitureInteractEvent e) { + /** A finished alcoholic brew replaces the water or milk, so both add-in slots stay free. */ + private void pourBrew(Player p, BrewLiquid poured) { + if (!poured.alcoholic()) { + p.sendMessage("§cOnly alcoholic brews can go in the saucepan."); + return; + } + if (f.getType() == null || f.getType().getSlot("liquid") == null) return; + brew = poured; + liquidSource = brewIngredient(poured); + saveLiquid(null); + f.getVariables().put(VAR_BREW, poured.stars() + ":" + poured.name()); + secondaries.put("liquid", -1); + if (poured.colour() != null) addColour(poured.colour()); + saveColours(); + HeldStack.useOneKeepStack(p, new ItemStack(Material.GLASS_BOTTLE)); + p.swingMainHand(); + updateModel(); + danger = 0; + f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BOTTLE_EMPTY, 1f, 1f); + } + + /** The brew counts toward the sauce's stars like the water or milk it replaces. */ + private static FoodItem brewIngredient(BrewLiquid poured) { + FoodItem ingredient = FoodParser.parse("sauce(type=sauce;origin=Mixed)").template; + ingredient.setCategory("alcohol"); + ingredient.setOrigin(poured.name()); + ingredient.setQualityRange(poured.stars(), poured.stars()); + return ingredient; + } + + /** The brew leads the sauce's name, e.g. "Red Wine Sauce". */ + @Override + public List getIngredients() { + List names = super.getIngredients(); + if (brew == null || brew.name().isEmpty()) return names; + List withBrew = new ArrayList<>(); + withBrew.add(brew.name()); + withBrew.addAll(names); + return withBrew; + } + public void updateModel() { String path = getLiquidItemPath(); @@ -452,10 +509,12 @@ public void updateModel() { @Override public void clear() { liquidSource = null; + brew = null; super.clear(); if (f == null) return; f.getVariables().remove(VAR_COLOURS); f.getVariables().remove(VAR_LIQUID); + f.getVariables().remove(VAR_BREW); InteractibleFurniture.getInstance().getFurnitureManager().markDirty(f); } @@ -472,6 +531,8 @@ public void rebuildFromFurniture() { secondaries.put("liquid", -1); ItemStack liquid = PotReference.decodeStack(stringVariable(VAR_LIQUID)); liquidSource = liquid == null ? null : FoodItem.fromItem(liquid); + brew = restoreBrew(stringVariable(VAR_BREW)); + if (brew != null) liquidSource = brewIngredient(brew); restoreColours(); } @@ -498,6 +559,17 @@ private void restoreColours() { } } + /** Saved as "stars:name". Names may contain colons, the star count never does. */ + private static BrewLiquid restoreBrew(String saved) { + int split = saved == null ? -1 : saved.indexOf(':'); + if (split <= 0) return null; + try { + return new BrewLiquid(saved.substring(split + 1), Integer.parseInt(saved.substring(0, split)), null, true); + } catch (NumberFormatException invalid) { + return null; + } + } + private String stringVariable(String key) { return f.getVariables().get(key) instanceof String text ? text : null; } diff --git a/src/main/resources/tags.yml b/src/main/resources/tags.yml index 3b20377..d3e1f70 100644 --- a/src/main/resources/tags.yml +++ b/src/main/resources/tags.yml @@ -211,3 +211,11 @@ rising: name: "#e6c98aRisen" value: 900 stars: 0 + +# A BreweryX brew poured into the saucepan as its liquid. +reduced: + reduced: + name: "#a8455aReduced" + value: 0 + nutrition-mult: 1.10 + food-mult: 1.10 diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BrewLiquidTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BrewLiquidTest.java new file mode 100644 index 0000000..81c727d --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/brewery/BrewLiquidTest.java @@ -0,0 +1,70 @@ +package net.tfminecraft.cooking.brewery; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.dre.brewery.api.BreweryApi; +import com.dre.brewery.recipe.BRecipe; +import com.dre.brewery.recipe.PotionColor; + +class BrewLiquidTest { + @AfterEach void resetReader() { BrewLiquid.setReader(null); } + + @Test void breweryQualityRoundsHalfStarsUp() { + int[] expected = {1, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 5}; + for (int quality = 0; quality <= 11; quality++) { + assertEquals(expected[quality], BrewLiquid.starsFor(quality), "quality " + quality); + } + assertEquals(1, BrewLiquid.starsFor(-3)); + } + + @Test void recipeNamesLoseTheirColourCodes() { + assertEquals("Golden Mead", BrewLiquid.plainName("&6Golden Mead")); + assertEquals("Shimmering Golden Vodka", BrewLiquid.plainName(" §6§lShimmering &#ffaa00Golden Vodka ")); + assertEquals("Red Wine", BrewLiquid.plainName("Red Wine")); + assertEquals("", BrewLiquid.plainName(null)); + } + + @Test void withoutBreweryNothingIsABrew() { + ItemStack potion = new ItemStack(Material.POTION); + assertNull(BrewLiquid.from(potion)); + BrewLiquid wine = new BrewLiquid("Red Wine", 5, "ff0000", true); + BrewLiquid.setReader(item -> wine); + assertSame(wine, BrewLiquid.from(potion)); + assertNull(BrewLiquid.from(null)); + } + + @Test void readsTheRecipeNameStarsColourAndAlcohol() { + BRecipe recipe = mock(BRecipe.class); + PotionColor colour = mock(PotionColor.class); + assertNull(BrewLiquidReader.describe(null, 7), "Still fermenting, no recipe yet"); + + when(recipe.getName(9)).thenReturn("&6Golden Mead"); + when(recipe.getAlcohol()).thenReturn(9); + when(recipe.getColor()).thenReturn(colour); + when(colour.getColor()).thenReturn(Color.fromRGB(0xff9900)); + assertEquals(new BrewLiquid("Golden Mead", 5, "ff9900", true), BrewLiquidReader.describe(recipe, 9)); + + when(recipe.getAlcohol()).thenReturn(-6); + when(colour.getColor()).thenReturn(null); + assertEquals(new BrewLiquid("Golden Mead", 5, null, false), BrewLiquidReader.describe(recipe, 9)); + + when(recipe.getColor()).thenReturn(null); + assertNull(BrewLiquidReader.describe(recipe, 9).colour()); + } + + @Test void itemsThatAreNotBrewsAreNotLiquids() { + ItemStack potion = new ItemStack(Material.POTION); + try (MockedStatic api = mockStatic(BreweryApi.class)) { + assertNull(BrewLiquidReader.read(potion)); + api.verify(() -> BreweryApi.getBrew(potion)); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java index 3d603d2..b3eb00a 100644 --- a/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java +++ b/src/test/java/net/tfminecraft/cooking/brewery/BreweryIngredientTest.java @@ -238,6 +238,17 @@ class BreweryIngredientTest { bridge.close();bridge.close(); } + @Test void registrationLetsTheSaucepanReadBrewsUntilClosed() { + ItemStack potion = new ItemStack(Material.POTION); + try (MockedStatic api = mockStatic(BreweryApi.class)) { + assertNull(BrewLiquid.from(potion)); + api.verify(() -> BreweryApi.getBrew(potion), times(1)); + cleanup.run(); + assertNull(BrewLiquid.from(potion)); + api.verify(() -> BreweryApi.getBrew(potion), times(1)); + } + } + @Test void failedListenerRegistrationRemovesTheGate() { JavaPlugin owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); var manager = owner.getServer().getPluginManager(); diff --git a/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceBrewTest.java b/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceBrewTest.java new file mode 100644 index 0000000..df38787 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceBrewTest.java @@ -0,0 +1,152 @@ +package net.tfminecraft.cooking.cooking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import net.tfminecraft.cooking.brewery.BrewLiquid; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** A BreweryX brew can be the saucepan's liquid in place of water or milk. */ +class SauceReferenceBrewTest { + private static final BrewLiquid WINE = new BrewLiquid("Red Wine", 5, "ff0000", true); + private CookingReferencesTest.Environment env; + + @BeforeEach void setUp() { + env = new CookingReferencesTest.Environment(); + BrewLiquid.setReader(item -> item.getType() == Material.POTION && item.hasItemMeta() + && item.getItemMeta().hasDisplayName() ? brewNamed(item.getItemMeta().getDisplayName()) : null); + } + @AfterEach void tearDown() { + BrewLiquid.setReader(null); + env.close(); + } + + private static BrewLiquid brewNamed(String name) { + return switch (name) { + case "coffee" -> new BrewLiquid("Coffee", 4, "000000", false); + case "vodka" -> new BrewLiquid("Vodka", 3, "ffffff", true); + case "plain" -> new BrewLiquid("", 2, null, true); + default -> WINE; + }; + } + + private static ItemStack brew(String name) { + ItemStack potion = new ItemStack(Material.POTION); + var meta = potion.getItemMeta(); + meta.setDisplayName(name); + potion.setItemMeta(meta); + return potion; + } + + private SauceReference pour(String name) { + SauceReference sauce = new SauceReference(env.furniture, Method.SAUCEPAN); + env.player.getInventory().setItemInMainHand(brew(name)); + sauce.interact(env.interaction()); + return sauce; + } + + private ItemStack scoop(SauceReference sauce) { + env.player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + sauce.interact(env.interaction()); + return env.player.getInventory().getItemInMainHand(); + } + + @Test + void anAlcoholicBrewBecomesTheLiquidAndLeavesBothAddInSlotsFree() { + SauceReference sauce = pour("wine"); + + assertEquals(Map.of("liquid", -1), sauce.getSecondaries()); + assertTrue(sauce.getSlots().isEmpty()); + assertEquals(List.of("ff0000"), sauce.getColours()); + assertEquals("5:Red Wine", env.variables.get("sauce.brew")); + assertFalse(env.variables.containsKey("sauce.liquid")); + assertEquals(Material.GLASS_BOTTLE, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(List.of("Red Wine"), sauce.getIngredients()); + + env.player.getInventory().setItemInMainHand(env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 1)); + sauce.interact(env.interaction()); + env.player.getInventory().setItemInMainHand(env.stack(env.food("garlic", "garnish", "Garlic"), Material.SUGAR, 1)); + sauce.interact(env.interaction()); + assertEquals(2, sauce.getSlots().size()); + assertEquals(List.of("Red Wine", "Garlic"), sauce.getIngredients()); + + ItemStack served = scoop(sauce); + + assertTrue(served.getItemMeta().getDisplayName().contains("Red Wine")); + assertNotNull(env.lastBuilt.getTagTrack("reduced")); + env.composer.verify(() -> CompositionQualityResolver.compose(any(), argThat(inputs -> { + FoodItem liquid = inputs.iterator().next(); + return inputs.size() == 3 && "alcohol".equals(liquid.getCategory()) + && "Red Wine".equals(liquid.getOrigin()) && liquid.getQualityMin() == 5; + }), any())); + assertTrue(sauce.isEmpty()); + assertFalse(env.variables.containsKey("sauce.brew")); + } + + @Test + void aWhiteBrewIsNotMilkAndAnUnnamedBrewKeepsTheUsualName() { + scoop(pour("vodka")); + assertNull(env.lastBuilt.getTagTrack("sauce_creamy")); + assertNotNull(env.lastBuilt.getTagTrack("reduced")); + + SauceReference plain = pour("plain"); + assertTrue(plain.getColours().isEmpty()); + assertTrue(plain.getIngredients().isEmpty()); + } + + @Test + void nonAlcoholicBrewsAndBrewsIntoAFullPanAreRefused() { + SauceReference coffee = pour("coffee"); + assertTrue(coffee.isEmpty()); + assertEquals(Material.POTION, env.player.getInventory().getItemInMainHand().getType()); + verify(env.player).sendMessage("§cOnly alcoholic brews can go in the saucepan."); + + SauceReference wine = pour("wine"); + env.player.getInventory().setItemInMainHand(brew("vodka")); + wine.interact(env.interaction()); + assertEquals(List.of("ff0000"), wine.getColours()); + assertEquals(Material.POTION, env.player.getInventory().getItemInMainHand().getType()); + + env.definitions.remove("liquid"); + assertTrue(pour("wine").isEmpty()); + when(env.furniture.getType()).thenReturn(null); + assertTrue(pour("wine").isEmpty()); + } + + @Test + void aRebuiltSaucepanKeepsItsBrew() { + pour("wine"); + + SauceReference restored = new SauceReference(env.furniture, Method.SAUCEPAN); + restored.rebuildFromFurniture(); + + assertEquals(List.of("Red Wine"), restored.getIngredients()); + assertEquals(List.of("ff0000"), restored.getColours()); + scoop(restored); + assertNotNull(env.lastBuilt.getTagTrack("reduced")); + env.composer.verify(() -> CompositionQualityResolver.compose(any(), argThat(inputs -> + inputs.size() == 1 && "alcohol".equals(inputs.iterator().next().getCategory())), any())); + } + + @Test + void unreadableSavedBrewsAreIgnored() { + for (String saved : List.of("Red Wine", ":Red Wine", "five:Red Wine")) { + pour("wine"); + env.variables.put("sauce.brew", saved); + SauceReference restored = new SauceReference(env.furniture, Method.SAUCEPAN); + restored.rebuildFromFurniture(); + assertTrue(restored.getIngredients().isEmpty(), saved); + restored.clear(); + } + } +}