From b22096b63c580c90834a39ec0efddded3d8d6f99 Mon Sep 17 00:00:00 2001 From: Justinas Launikonis <95593194+JustinasLa@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:15:45 +0200 Subject: [PATCH] feat: hide red-mushroom poison in sandwiches, salads, pizzas, pies and soup Grilled mushrooms go into sandwiches and salads, raw or grilled ones onto pizzas and into pies, and raw ones boil and mash into soup (cooking-options raw-in-cookware keeps them from being Raw in hand). In a dish both kinds are just "Mushroom". Dish bonuses can now count an origin (count: origin) and keep their tag secret (hide-at-stars, hide-tags, hide-dish-tags). A hidden tag stays on the item (TagTrack hidden, hidden_tags PDC, hidden= in food strings) and is eaten, but the lore leaves out its line, and its stars, Food and Nutrition show without it; it never names the food or stacks with a shown one. The default configs give every dish and the soup pot a poison bonus: each red mushroom makes it Tainted, Poisoned, then Deadly (Nutrition x0.4, x0.15, x0), hidden at 3 stars or more while the red mushrooms are grilled (pizza, pie: the dish is cooked; soup: boiled). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../cooking/cooking/CookingOutput.java | 8 +- .../cooking/cooking/PotReference.java | 3 + .../tfminecraft/cooking/item/FoodItem.java | 86 ++++- .../cooking/item/data/CookData.java | 7 +- .../cooking/item/data/CookParameter.java | 13 + .../cooking/item/tag/TagTrack.java | 11 + .../net/tfminecraft/cooking/pie/PieItems.java | 5 +- .../tfminecraft/cooking/pizza/PizzaItems.java | 13 +- .../tfminecraft/cooking/pot/PotConfig.java | 13 +- .../cooking/sandwich/DishBonus.java | 103 +++++- .../tfminecraft/cooking/utils/FoodParser.java | 12 + .../cooking/utils/ItemBuilder.java | 20 +- .../net/tfminecraft/cooking/utils/Keys.java | 3 + .../cooking/utils/NameComposer.java | 3 +- src/main/resources/models.yml | 46 +++ src/main/resources/naming.yml | 2 + src/main/resources/pies.yml | 23 +- src/main/resources/pizzas.yml | 25 ++ src/main/resources/pot.yml | 15 + src/main/resources/salads.yml | 18 + src/main/resources/sandwiches.yml | 28 +- src/main/resources/tags.yml | 18 + src/main/resources/types.yml | 8 + .../cooking/PoisonSoupCoverageTest.java | 156 +++++++++ .../item/tag/HiddenTagCoverageTest.java | 200 +++++++++++ .../sandwich/PoisonBonusCoverageTest.java | 321 ++++++++++++++++++ 27 files changed, 1109 insertions(+), 52 deletions(-) create mode 100644 src/test/java/net/tfminecraft/cooking/cooking/PoisonSoupCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/tag/HiddenTagCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/sandwich/PoisonBonusCoverageTest.java diff --git a/README.md b/README.md index 3ae119e..608e8e9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **Salads** — right-click a placed bowl with chopped or cooked veg, fruit, cheese or meat, add a dressing or seasoning, and toss it with an empty hand. It stays in the bowl and is eaten from it. Its ingredients name it, its kind (fruit, greek, carrot, mixed) gives it its look, and it earns bonuses such as Dressed or Crisp (`salads.yml`). - **Stock and mashed potatoes** — boil two bones in a cup of water in the saucepan and bottle the stock (`condiments.yml`); a soup started with stock instead of water is Rich. Put four boiled cut potatoes and a butter (with milk or salt if you like) in an empty pot, mash them without water or heat, and ladle the mash onto a plate beside the other food (`pot.yml`). - **Pies** — line a pie tin with dough, fill it with fruit, meat, mashed potato, vegetables or cheese, and bake it in the oven like the bread tray. The whole pie comes out of the tin with an empty hand and is cut into four slices on the cutting board. Its fillings make its kind (Apple, Key Lime, Mixed Berry, Meat, Shepherd's) and name, and its food, quality and freshness come from its dough and fillings (`pies.yml`). +- **Mushrooms and hidden poison** — grilled mushrooms go into sandwiches and salads, raw or grilled ones onto pizzas and into pies, and raw ones boil into soup; in a dish both kinds are just "Mushroom". Each red mushroom poisons the dish a step further (Tainted, Poisoned, Deadly: less and less nutrition, the same food). In a dish of 3 stars or more with its red mushrooms grilled (or its own bake cooked, its soup boiled) the poison is hidden: the lore shows no tag and the Food and Nutrition it would have without it, but eating applies it. Any dish bonus can count an origin and hide its tag this way (`count: origin`, `hide-at-stars`, `hide-tags`, `hide-dish-tags` in the dishes' `bonuses`; `tags.yml` poison). - **Serving and sharing** — plate dishes, ladle soup into bowls, and carve foods into portions for the table. - **Quality and freshness** — ingredient quality and composition influence finished dishes, while aging and food tags carry preparation history through the kitchen. - **Farming and husbandry** — crop fertility and harvest quality sit alongside animal care, breeding, genetics, and produce collection. diff --git a/src/main/java/net/tfminecraft/cooking/cooking/CookingOutput.java b/src/main/java/net/tfminecraft/cooking/cooking/CookingOutput.java index ff9fae3..641250b 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/CookingOutput.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/CookingOutput.java @@ -23,11 +23,13 @@ public final class CookingOutput { private CookingOutput() {} /** - * A piece whose type has no doneness of its own (cooking-options with only outputs) starts raw once it's in the - * cookware, so the usual raw, cooked, burnt steps can follow. + * A piece whose type has no doneness of its own (cooking-options with only outputs or raw-in-cookware) starts raw + * once it's in the cookware, so the usual raw, cooked, burnt steps can follow. */ static void startRaw(FoodItem piece, Method method) { - if (output(piece, method) == null || piece.getCookData().cooksInPlace() || piece.hasTagTrack(COOKED_TRACK)) { + CookParameter parameter = piece.getCookData().getParameters().get(method); + if (parameter == null || !parameter.rawInCookwareOnly() || piece.getCookData().cooksInPlace() + || piece.hasTagTrack(COOKED_TRACK)) { return; } piece.addTagTrack(COOKED_TRACK); diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index d96f709..5d5cb03 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -46,6 +46,7 @@ import net.tfminecraft.cooking.quality.CompositionApplier; import net.tfminecraft.cooking.quality.CompositionQualityResolver; import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.sandwich.DishBonus; import net.tfminecraft.cooking.utils.DisplayUtils; import net.tfminecraft.cooking.utils.Encoder; import net.tfminecraft.cooking.utils.FoodParser; @@ -244,6 +245,8 @@ public void scoop(Player p, ItemStack ladle) { CompositionResult composed = CompositionQualityResolver.compose(p, inputs, CompositionContext.SOUP_SCOOP); CompositionApplier.apply(soup, composed); soup.setBaseFood(scoopFood(soup.getBaseFood())); + // The pot has no layers: only bonuses that count origins (a poison) apply, over everything in it. + DishBonus.apply(soup, inputs, food -> null, layer -> false, PotConfig.bonuses()); int quality = composed.getFinalQuality(); ItemStack output = ItemBuilder.buildSingleWithQuality(soup, ladle, quality); diff --git a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java index f25b183..3f299a5 100644 --- a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java +++ b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java @@ -317,6 +317,19 @@ public String getTagLabel(String trackId, String stepId) { public int getEffectiveQuality() { return QualityUtils.clamp(qualityMin + TagQuality.stars(tags.values())); } + + /** The stars the lore shows: as {@link #getEffectiveQuality()}, leaving out hidden tracks. */ + public int getShownQuality() { + return QualityUtils.clamp(qualityMin + TagQuality.stars(shownTracks())); + } + + private List shownTracks() { + List shown = new ArrayList<>(); + for (TagTrack track : tags.values()) { + if (!track.isHidden()) shown.add(track); + } + return shown; + } public void setQualityRange(int min, int max) { this.qualityMin = min; this.qualityMax = max; @@ -575,9 +588,27 @@ public List getCurrentTagIds() { return current; } + /** The ids of the tracks the lore keeps secret (TagTrack.isHidden), in lore order. */ + public List getHiddenTrackIds() { + List hidden = new ArrayList<>(); + for (TagTrack track : getTagTracks()) { + if (track.isHidden()) hidden.add(track.getId()); + } + return hidden; + } + + /** Hides the tracks with these ids that the food has; ids it doesn't have are ignored. */ + public void hideTracks(Collection trackIds) { + for (String trackId : trackIds) { + TagTrack track = tags.get(trackId); + if (track != null) track.setHidden(true); + } + } + + /** True when both have the same tag steps, hidden alike, so a secret tag never stacks into a plain food. */ public boolean sameTags(FoodItem other) { - List myTags = getCurrentTagIds(); - List otherTags = other.getCurrentTagIds(); + List myTags = tagKeys(); + List otherTags = other.tagKeys(); if(myTags.size() != otherTags.size()) return false; for(String t : myTags) { if(!otherTags.contains(t)) return false; @@ -585,6 +616,15 @@ public boolean sameTags(FoodItem other) { return true; } + private List tagKeys() { + List keys = new ArrayList<>(); + for (TagTrack track : tags.values()) { + TagStep step = track.getCurrentStep(); + if (step != null) keys.add(step.getId() + (track.isHidden() ? "~hidden" : "")); + } + return keys; + } + public boolean hasTag(Tag tag) { for(TagStep t : getCurrentTags()) { if(t.getTag().equals(tag)) return true; @@ -601,31 +641,46 @@ public boolean canBeCooked() { // -------------------------------------------------------------- // FINAL VALUES (QUALITY + TAGS + COOK STATE) // -------------------------------------------------------------- + /** The food eating this gives: every tag counts, hidden ones too. */ public double getFinalFood() { - if (hasCarveState() && carveRemaining > 0) { - return roundToTenth(applyMultipliers(net.tfminecraft.cooking.carve.CarvableRoastUtils.getRemainingFood(this), 0) - + sauceFinal(0)); - } - return roundToTenth(applyMultipliers(baseFood, 0) + sauceFinal(0)); + return finalValue(0, true); } + /** The nutrition eating this gives: every tag counts, hidden ones too. */ public double getFinalNutrition() { + return finalValue(1, true); + } + + /** The food the lore shows: as {@link #getFinalFood()}, leaving out hidden tracks. */ + public double getShownFood() { + return finalValue(0, false); + } + + /** The nutrition the lore shows: as {@link #getFinalNutrition()}, leaving out hidden tracks. */ + public double getShownNutrition() { + return finalValue(1, false); + } + + private double finalValue(int type, boolean withHidden) { + double base; if (hasCarveState() && carveRemaining > 0) { - return roundToTenth(applyMultipliers(net.tfminecraft.cooking.carve.CarvableRoastUtils.getRemainingNutrition(this), 1) - + sauceFinal(1)); + base = type == 1 ? net.tfminecraft.cooking.carve.CarvableRoastUtils.getRemainingNutrition(this) + : net.tfminecraft.cooking.carve.CarvableRoastUtils.getRemainingFood(this); + } else { + base = type == 1 ? baseNutrition : baseFood; } - return roundToTenth(applyMultipliers(baseNutrition, 1) + sauceFinal(1)); + return roundToTenth(applyMultipliers(base, type, withHidden) + sauceFinal(type, withHidden)); } - private double sauceFinal(int type) { + private double sauceFinal(int type, boolean withHidden) { if (!hasSauce()) { return 0; } - return type == 1 ? getSauce().getFinalNutrition() : getSauce().getFinalFood(); + return getSauce().finalValue(type, withHidden); } - private double applyMultipliers(double base, int type) { - int stars = getEffectiveQuality(); + private double applyMultipliers(double base, int type, boolean withHidden) { + int stars = withHidden ? getEffectiveQuality() : getShownQuality(); double qualityMultiplier = type == 1 ? net.tfminecraft.cooking.quality.QualityConfig.nutritionMultiplier(stars) : 1.0 + (stars - 1) * 0.20; @@ -633,6 +688,7 @@ private double applyMultipliers(double base, int type) { double result = base * qualityMultiplier; for (TagTrack t : tags.values()) { + if (!withHidden && t.isHidden()) continue; TagStep step = t.getCurrentStep(); if (step != null) { if (type == 1) result *= step.getNutritionMultiplier(); @@ -713,6 +769,8 @@ public static FoodItem fromItem(ItemStack stack) { out.addOrModifyTrack(t); } } + String hiddenData = pdc.get(Keys.HIDDEN_TAGS, PersistentDataType.STRING); + if (hiddenData != null) out.hideTracks(Arrays.asList(hiddenData.split(":"))); out.decodeAgeRemainder(pdc.get(Keys.AGE_REMAINDER, PersistentDataType.STRING)); diff --git a/src/main/java/net/tfminecraft/cooking/item/data/CookData.java b/src/main/java/net/tfminecraft/cooking/item/data/CookData.java index e97b6cc..d438c6d 100644 --- a/src/main/java/net/tfminecraft/cooking/item/data/CookData.java +++ b/src/main/java/net/tfminecraft/cooking/item/data/CookData.java @@ -21,7 +21,7 @@ public CookData(String id, ConfigurationSection config) { ConfigurationSection param = config.getConfigurationSection(key); try { parameters.put(Method.valueOf(key.toUpperCase(java.util.Locale.ROOT)), new CookParameter(param.getInt("tag", 1), param.getInt("time", 15), param.getInt("burn", 30), - param.getString("output", null))); + param.getString("output", null), param.getBoolean("raw-in-cookware", false))); } catch (Exception e) { Bukkit.getLogger().info("Failed to create cooking data for "+id); } @@ -56,11 +56,12 @@ public boolean hasMethod(Method m) { /** * True when some method cooks this food as itself, so it carries a cooked track (Raw) from the start. A food whose - * only options name an output (chopped corn, which pops into popcorn) is not raw until it's in the cookware. + * only options name an output (chopped corn, which pops into popcorn) or are raw-in-cookware (a mushroom, which + * boils in the pot) is not raw until it's in the cookware. */ public boolean cooksInPlace() { for (CookParameter parameter : parameters.values()) { - if (parameter.getOutput() == null) { + if (!parameter.rawInCookwareOnly()) { return true; } } diff --git a/src/main/java/net/tfminecraft/cooking/item/data/CookParameter.java b/src/main/java/net/tfminecraft/cooking/item/data/CookParameter.java index d44158a..99a646b 100644 --- a/src/main/java/net/tfminecraft/cooking/item/data/CookParameter.java +++ b/src/main/java/net/tfminecraft/cooking/item/data/CookParameter.java @@ -3,22 +3,30 @@ /** * One cooking option of a food type (types.yml cooking-options.<method>): the cooked tag it reaches, when, when * it burns, and optionally the food it turns into once it's done (output), such as chopped corn popping into popcorn. + * An option with an output, or with raw-in-cookware: true (a mushroom boiling in the pot), doesn't make the food Raw + * in hand: it starts raw once it's in that cookware. */ public class CookParameter { private int tag; private int time; private int burntime; private final String output; + private final boolean rawInCookware; public CookParameter(int tag, int time, int burntime) { this(tag, time, burntime, null); } public CookParameter(int tag, int time, int burntime, String output) { + this(tag, time, burntime, output, false); + } + + public CookParameter(int tag, int time, int burntime, String output, boolean rawInCookware) { this.tag = tag; this.time = time; this.burntime = burntime; this.output = output == null || output.isBlank() ? null : output.trim(); + this.rawInCookware = rawInCookware; } public int getTag() { @@ -36,6 +44,11 @@ public String getOutput() { return output; } + /** True when the food is only Raw once it's in this cookware: the option has an output or raw-in-cookware. */ + public boolean rawInCookwareOnly() { + return output != null || rawInCookware; + } + /** The same timings without an output: what the piece keeps cooking by once it has turned into its output. */ public CookParameter withoutOutput() { return output == null ? this : new CookParameter(tag, time, burntime); diff --git a/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java b/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java index 6146766..ac22159 100644 --- a/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java +++ b/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java @@ -6,11 +6,17 @@ import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; +/** + * One tags.yml track on a food: its steps and the value that picks the current one. A hidden track (a poison a cook + * slipped in) still applies its multipliers when the food is eaten, but the lore leaves its line out and shows Food + * and Nutrition without it (FoodItem getShownFood / getShownNutrition); the item keeps it hidden through rebuilds. + */ public class TagTrack { private String id; private boolean ageable; private int value; private int index; + private boolean hidden; private final List steps = new ArrayList<>(); public TagTrack(String key, int i, ConfigurationSection config) { @@ -34,6 +40,7 @@ public TagTrack(TagTrack other) { this.value = other.value; this.ageable = other.ageable; this.index = other.index; + this.hidden = other.hidden; this.steps.addAll(other.steps); } @@ -64,6 +71,10 @@ public void forceSetValue(int value) { } public boolean isAgeable() { return ageable; } + /** True when the lore keeps this track secret: its line and its effect on the shown Food and Nutrition. */ + public boolean isHidden() { return hidden; } + public void setHidden(boolean hidden) { this.hidden = hidden; } + public TagStep getCurrentStep() { if (steps.isEmpty()) return null; TagStep current = null; diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieItems.java b/src/main/java/net/tfminecraft/cooking/pie/PieItems.java index 67f7b46..97d05c2 100644 --- a/src/main/java/net/tfminecraft/cooking/pie/PieItems.java +++ b/src/main/java/net/tfminecraft/cooking/pie/PieItems.java @@ -52,14 +52,15 @@ public static ItemStack build(Player player, ItemStack dough, List fi CompositionApplier.apply(pie, composed); CompositionFreshnessApplier.keepOwnAgeing(pie); SandwichItems.applyTotals(pie, all, true); - DishBonus.apply(pie, all, PieConfig::layerFor, layer -> !PieConfig.isCrust(layer), PieConfig.bonuses()); - name(pie, filled); TagTrack cooked = TrackLoader.getByString(COOKED_TRACK); if (cooked != null) { TagTrack bake = new TagTrack(cooked); bake.setValue(baked); pie.addOrModifyTrack(bake); } + // After the bake: a bonus hidden in a well-made pie (a poison) needs it cooked, not burnt. + DishBonus.apply(pie, all, PieConfig::layerFor, layer -> !PieConfig.isCrust(layer), PieConfig.bonuses()); + name(pie, filled); WarmthUtils.applyHot(pie); return ItemBuilder.buildComposedWithQuality(pie, composed.getFinalQuality()); } diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java index aa1d617..151bc4f 100644 --- a/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java @@ -47,6 +47,13 @@ public static ItemStack build(Player player, List stacks, int baked) CompositionApplier.apply(pizza, composed); CompositionFreshnessApplier.keepOwnAgeing(pizza); applyTotals(pizza, layers); + TagTrack cooked = TrackLoader.getByString(COOKED_TRACK); + if (cooked != null) { + TagTrack bake = new TagTrack(cooked); + bake.setValue(baked); + pizza.addOrModifyTrack(bake); + } + // After the bake: a bonus hidden in a well-made pizza (a poison) needs it cooked, not burnt. DishBonus.apply(pizza, layers, PizzaConfig::layerFor, layer -> !PizzaConfig.isBase(layer) && !PizzaConfig.isSauce(layer), PizzaConfig.bonuses()); for (FoodItem food : layers) { @@ -56,12 +63,6 @@ public static ItemStack build(Player player, List stacks, int baked) String name = layer.fillerName(food); if (!name.isEmpty()) pizza.addIngredient(name); } - TagTrack cooked = TrackLoader.getByString(COOKED_TRACK); - if (cooked != null) { - TagTrack bake = new TagTrack(cooked); - bake.setValue(baked); - pizza.addOrModifyTrack(bake); - } return ItemBuilder.buildComposedWithQuality(pizza, composed.getFinalQuality()); } diff --git a/src/main/java/net/tfminecraft/cooking/pot/PotConfig.java b/src/main/java/net/tfminecraft/cooking/pot/PotConfig.java index 973d4c5..352f05c 100644 --- a/src/main/java/net/tfminecraft/cooking/pot/PotConfig.java +++ b/src/main/java/net/tfminecraft/cooking/pot/PotConfig.java @@ -8,11 +8,16 @@ import org.bukkit.configuration.file.YamlConfiguration; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.DishBonus; -/** pot.yml: what the soup pot takes besides a water bucket (stock), and the dishes it mashes without water. */ +/** + * pot.yml: what the soup pot takes besides a water bucket (stock), the dishes it mashes without water, and the tags a + * soup earns from what's in the pot (bonuses, such as a poison). + */ public final class PotConfig { private static List liquids = List.of(); private static List mashes = List.of(); + private static List bonuses = List.of(); public void load(File file) { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); @@ -34,6 +39,12 @@ public void load(File file) { } liquids = poured; mashes = recipes; + bonuses = DishBonus.load(config.getConfigurationSection("bonuses")); + } + + /** The tags a ladled soup earns from what's in the pot, written like sandwiches.yml's bonuses (count: origin). */ + public static List bonuses() { + return bonuses; } /** The liquid this food pours into the pot, or null. */ diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java index d5ab7ef..5be077f 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -20,15 +21,24 @@ import net.tfminecraft.cooking.utils.StationAddonRules; /** - * A tag a finished sandwich or pizza earns from its layers (sandwiches.yml / pizzas.yml bonuses), like a soup's - * Seasoned or Thick: a step of a tags.yml track, whose multipliers then apply to the dish. + * A tag a finished dish earns from what's in it (sandwiches.yml, pizzas.yml, salads.yml, pies.yml and pot.yml + * bonuses), like a soup's Seasoned or Thick: a step of a tags.yml track, whose multipliers then apply to the dish. + * A bonus can keep its tag secret (TagTrack.isHidden) on a dish made well enough: a poison is hidden in good cooking. * *
  * bonuses:
  *   loaded:
  *     track: dish_size
- *     count: fillings          # fillings (or toppings), or seasonings: different salts and peppers
- *     steps: {3: hearty, 5: loaded}
+ *     count: fillings          # fillings (or toppings), seasonings: different salts and peppers, or origin: the
+ *     steps: {3: hearty, 5: loaded}   # dish's foods of origin below, raw or cooked, layer or not
+ *   poison:
+ *     track: poison
+ *     count: origin
+ *     origin: Red Mushroom
+ *     steps: {1: tainted, 2: poisoned, 3: deadly}
+ *     hide-at-stars: 3         # hidden when the dish ends at 3 stars or more, and
+ *     hide-tags: [cooked]      # every food the bonus counts is at one of these tag steps (grilled), and
+ *     hide-dish-tags: [cooked] # the dish itself is (its bake); each list only when given
  *   well_rounded:
  *     track: well_rounded
  *     layers: [cheese, vegetable]   # every one of these layers
@@ -51,7 +61,11 @@ public final class DishBonus {
     private final boolean valuable;
     private final boolean fresh;
     private final String count;
+    private final String origin;
     private final TreeMap steps = new TreeMap<>();
+    private final int hideAtStars;
+    private final List hideTags = new ArrayList<>();
+    private final List hideDishTags = new ArrayList<>();
 
     public DishBonus(ConfigurationSection config) {
         this.track = config.getString("track", config.getName());
@@ -64,6 +78,10 @@ public DishBonus(ConfigurationSection config) {
         this.valuable = config.getBoolean("valuable", false);
         this.fresh = config.getBoolean("fresh", false);
         this.count = config.getString("count");
+        this.origin = config.getString("origin", "");
+        this.hideAtStars = config.getInt("hide-at-stars", 0);
+        for (String id : config.getStringList("hide-tags")) hideTags.add(id.toLowerCase(Locale.ROOT));
+        for (String id : config.getStringList("hide-dish-tags")) hideDishTags.add(id.toLowerCase(Locale.ROOT));
         ConfigurationSection counted = config.getConfigurationSection("steps");
         if (counted != null) {
             for (String at : counted.getKeys(false)) {
@@ -88,8 +106,10 @@ public static List load(ConfigurationSection section) {
     }
 
     /**
-     * Gives the dish every bonus its layers earn. The layer function says which layer a food makes (null for none);
-     * the filling test picks the layers that count as fillings or toppings (not bread, dough or sauce).
+     * Gives the dish every bonus its foods earn. The layer function says which layer a food makes (null for none,
+     * as for everything in a soup); the filling test picks the layers that count as fillings or toppings (not bread,
+     * dough or sauce). Call it once the dish's quality and own tags (a pizza's bake) are set: a bonus that can hide its
+     * tag decides that from them, after every bonus is in.
      */
     public static void apply(FoodItem dish, List foods, Function layerFor,
             Predicate filling, List bonuses) {
@@ -97,27 +117,80 @@ public static void apply(FoodItem dish, List foods, Function addonsByOrigin = new HashMap<>();
         Set seasonings = new HashSet<>();
         Map all = new HashMap<>();
-        int fillings = 0;
+        List fillings = new ArrayList<>();
+        List seasoned = new ArrayList<>();
         boolean allFresh = true;
         for (FoodItem food : foods) {
             SandwichLayer layer = layerFor.apply(food);
             if (layer == null) continue;
             all.put(String.valueOf(all.size()), food);
             counts.merge(layer.getId().toLowerCase(Locale.ROOT), 1, Integer::sum);
-            if (filling.test(layer)) fillings++;
+            if (filling.test(layer)) fillings.add(food);
             String origin = food.getOrigin() == null ? "" : food.getOrigin().toLowerCase(Locale.ROOT);
             if (StationAddonRules.isAddonCategory(food.getCategory())) addonsByOrigin.putIfAbsent(origin, food);
-            if (StationAddonRules.isSeasoningCategory(food.getCategory())) seasonings.add(origin);
+            if (StationAddonRules.isSeasoningCategory(food.getCategory())) {
+                seasonings.add(origin);
+                seasoned.add(food);
+            }
             TagTrack age = food.getTagTrack(FRESHNESS);
             if (age != null && age.getCurrentStep() != age.getSteps().get(0)) allFresh = false;
         }
         String profile = StationAddonRules.classifyAddons(addonsByOrigin).name();
         boolean hasValuable = StationAddonRules.hasValuable(all);
+        Map> hideable = new LinkedHashMap<>();
+        Map hiders = new HashMap<>();
         for (DishBonus bonus : bonuses) {
-            int counted = "seasonings".equalsIgnoreCase(bonus.count) ? seasonings.size() : fillings;
-            String earned = bonus.earned(counts, counted, profile, hasValuable, allFresh);
-            if (earned != null) give(dish, bonus.track, earned);
+            List counted;
+            int amount;
+            if ("seasonings".equalsIgnoreCase(bonus.count)) {
+                counted = seasoned;
+                amount = seasonings.size();
+            } else if ("origin".equalsIgnoreCase(bonus.count)) {
+                counted = bonus.ofOrigin(foods);
+                amount = counted.size();
+            } else {
+                counted = bonus.count == null ? new ArrayList<>(all.values()) : fillings;
+                amount = fillings.size();
+            }
+            String earned = bonus.earned(counts, amount, profile, hasValuable, allFresh);
+            String given = earned == null ? null : give(dish, bonus.track, earned);
+            if (given != null && bonus.hideAtStars > 0) {
+                hideable.put(given, counted);
+                hiders.put(given, bonus);
+            }
+        }
+        // Every tag is in, so the dish's stars are final.
+        hideable.forEach((trackId, counted) ->
+                dish.getTagTrack(trackId).setHidden(hiders.get(trackId).hides(dish, counted)));
+    }
+
+    /** The foods of this bonus's origin among all of the dish's, whatever layer they make. */
+    private List ofOrigin(List foods) {
+        List matching = new ArrayList<>();
+        for (FoodItem food : foods) {
+            if (origin.equalsIgnoreCase(food.getOrigin() == null ? "" : food.getOrigin())) matching.add(food);
+        }
+        return matching;
+    }
+
+    /**
+     * True when the dish keeps this bonus's tag secret: it has at least hide-at-stars stars, it is at a hide-dish-tags
+     * step (if any are given), and so is every food the bonus counted at a hide-tags step (if any are given).
+     */
+    boolean hides(FoodItem dish, List counted) {
+        if (dish.getEffectiveQuality() < hideAtStars) return false;
+        if (!hideDishTags.isEmpty() && !atAny(dish, hideDishTags)) return false;
+        for (FoodItem food : counted) {
+            if (!hideTags.isEmpty() && !atAny(food, hideTags)) return false;
+        }
+        return true;
+    }
+
+    private static boolean atAny(FoodItem food, List tags) {
+        for (String id : food.getCurrentTagIds()) {
+            if (tags.contains(id.toLowerCase(Locale.ROOT))) return true;
         }
+        return false;
     }
 
     /** The step this bonus gives, or null when the layers don't earn it ("" means the track's first step). */
@@ -134,9 +207,10 @@ String earned(Map counts, int counted, String profile, boolean
         return reached == null ? null : reached.getValue();
     }
 
-    private static void give(FoodItem dish, String trackId, String stepId) {
+    /** Gives the dish this step of the track; the track's id, or null when tags.yml has no such track. */
+    private static String give(FoodItem dish, String trackId, String stepId) {
         TagTrack template = TrackLoader.getByString(trackId);
-        if (template == null || template.getSteps().isEmpty()) return;
+        if (template == null || template.getSteps().isEmpty()) return null;
         TagTrack track = new TagTrack(template);
         int value = (int) track.getSteps().get(0).getRequiredValue();
         for (TagStep step : track.getSteps()) {
@@ -144,5 +218,6 @@ private static void give(FoodItem dish, String trackId, String stepId) {
         }
         track.forceSetValue(value);
         dish.addOrModifyTrack(track);
+        return track.getId();
     }
 }
diff --git a/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java b/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java
index 5464497..fd5c2f2 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java
@@ -120,6 +120,7 @@ private static Result parseRecipe(String input) {
         int qualMin = 1, qualMax = 5;
         boolean unique = true;
         boolean explicitQuality = false;
+        List hidden = List.of();
 
         FoodItem base = FoodLoader.getByString(fields.get("type"));
         if (base == null) return null;
@@ -204,6 +205,11 @@ private static Result parseRecipe(String input) {
                     }
                     break;
 
+                case "hidden":
+                    // Tracks the lore keeps secret (TagTrack.isHidden); applied once every tag is in.
+                    hidden = Arrays.asList(value.split(":"));
+                    break;
+
                 case "ingredients":
                     if (!value.isEmpty()) {
                         for (String ing : value.split(":"))
@@ -235,6 +241,7 @@ private static Result parseRecipe(String input) {
 
         item.setCategory(cat);
         item.setOrigin(originInput);
+        item.hideTracks(hidden);
 
         int finalAmount = ThreadLocalRandom.current().nextInt(amountMin, amountMax + 1);
         item.setAmount(finalAmount);
@@ -277,6 +284,11 @@ public static String toString(FoodItem f, int amount) {
         }
         sb.append(";");
 
+        List hidden = f.getHiddenTrackIds();
+        if (!hidden.isEmpty()) {
+            sb.append("hidden=").append(String.join(":", hidden)).append(";");
+        }
+
         if(f.getIngredients().size() >= 0) {
             // Ingredients
             sb.append("ingredients=");
diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java
index 2275747..2b90def 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java
@@ -221,6 +221,12 @@ public static ItemStack stamp(ItemStack stack, FoodItem item, String displayName
         } else {
             pdc.remove(Keys.TAGS);
         }
+        List hidden = item.getHiddenTrackIds();
+        if (!hidden.isEmpty()) {
+            pdc.set(Keys.HIDDEN_TAGS, PersistentDataType.STRING, String.join(":", hidden));
+        } else {
+            pdc.remove(Keys.HIDDEN_TAGS);
+        }
 
         if (item.hasSauce()) {
             pdc.set(Keys.SAUCE, PersistentDataType.STRING, FoodParser.toString(item.getSauce(), 1));
@@ -304,6 +310,10 @@ public static List buildLore(FoodItem item) {
         return buildLore(item, new HashMap<>());
     }
 
+    /**
+     * The food's lore. A hidden track (TagTrack.isHidden) gets no line, and the stars, Nutrition, Food and sauce shown
+     * leave it out, so the lore reads as if it weren't there; eating still applies it.
+     */
     static List buildLore(FoodItem item, Map indexMap) {
         String category = CategoryDictionary.getName(item.getCategory() != null ? item.getCategory() : "");
         String origin = item.hasTag(Tag.PROCESSED)
@@ -314,14 +324,14 @@ static List buildLore(FoodItem item, Map indexMap) {
             FoodItem sauce = item.getSauce();
             String sauceName = item.hasSauceName() ? item.getSauceName() : "Sauce";
             sauceLine = "§6" + sauceName + " §8("
-                    + DisplayUtils.getSauceStatString(sauce.getFinalFood(), sauce.getFinalNutrition()) + "§8)";
+                    + DisplayUtils.getSauceStatString(sauce.getShownFood(), sauce.getShownNutrition()) + "§8)";
         }
         return assembleLore(
                 category,
                 origin,
-                item.getEffectiveQuality(),
-                StringFormatter.formatHex("#d4ad77Nutrition §f" + item.getFinalNutrition()),
-                StringFormatter.formatHex("#d4ad77Food §f" + item.getFinalFood()),
+                item.getShownQuality(),
+                StringFormatter.formatHex("#d4ad77Nutrition §f" + item.getShownNutrition()),
+                StringFormatter.formatHex("#d4ad77Food §f" + item.getShownFood()),
                 item.getIngredients(),
                 sauceLine,
                 item,
@@ -380,7 +390,7 @@ static List assembleLore(
         if (tracks != null) {
             for (TagTrack t : tracks) {
                 TagStep step = t.getCurrentStep();
-                if (step == null) {
+                if (step == null || t.isHidden()) {
                     continue;
                 }
                 if (!shouldShowTagLore(tagLabels, t, step)) {
diff --git a/src/main/java/net/tfminecraft/cooking/utils/Keys.java b/src/main/java/net/tfminecraft/cooking/utils/Keys.java
index d5b34d7..69c2ccd 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/Keys.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/Keys.java
@@ -31,6 +31,9 @@ public class Keys {
 
     public static final NamespacedKey TAGS =
             new NamespacedKey(Cooking.plugin, "tags");
+    /** The ids of the food's hidden tag tracks (TagTrack.isHidden), joined by ':'. */
+    public static final NamespacedKey HIDDEN_TAGS =
+            new NamespacedKey(Cooking.plugin, "hidden_tags");
     public static final NamespacedKey AGE_REMAINDER =
             new NamespacedKey(Cooking.plugin, "age_remainder");
     public static final NamespacedKey LAST_UPDATE =
diff --git a/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java b/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java
index 21b34fd..cef26fa 100644
--- a/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java
+++ b/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java
@@ -63,7 +63,8 @@ public static String formatPrefixes(FoodItem item) {
         StringBuilder prefixes = new StringBuilder();
         for (String trackId : NamingConfig.getPrefixTracks()) {
             TagTrack track = item.getTagTrack(trackId);
-            if (track == null) {
+            // A hidden tag (a poison) never names the food.
+            if (track == null || track.isHidden()) {
                 continue;
             }
             TagStep step = track.getCurrentStep();
diff --git a/src/main/resources/models.yml b/src/main/resources/models.yml
index c094ed2..96f9e2d 100644
--- a/src/main/resources/models.yml
+++ b/src/main/resources/models.yml
@@ -3627,6 +3627,29 @@ brown_mushroom:
     display-data:
       rotation:
         x: 90
+  # Boiled in the soup pot (and so in a bowl of its soup) or burnt there, brown and red look alike.
+  boiled:
+    weight: 2
+    tags:
+      - boiled
+    gui:
+      item: ia.tfmc_cooking:boiled_mushroom
+    display:
+      item: ia.tfmc_cooking:boiled_mushroom
+    display-data:
+      rotation:
+        x: 90
+  burnt:
+    weight: 2
+    tags:
+      - burnt
+    gui:
+      item: ia.tfmc_cooking:grilled_mushroom_burnt
+    display:
+      item: ia.tfmc_cooking:grilled_mushroom_burnt
+    display-data:
+      rotation:
+        x: 90
 
 red_mushroom:
   fresh:
@@ -3649,6 +3672,29 @@ red_mushroom:
     display-data:
       rotation:
         x: 90
+  # Boiled in the soup pot (and so in a bowl of its soup) or burnt there, brown and red look alike.
+  boiled:
+    weight: 2
+    tags:
+      - boiled
+    gui:
+      item: ia.tfmc_cooking:boiled_mushroom
+    display:
+      item: ia.tfmc_cooking:boiled_mushroom
+    display-data:
+      rotation:
+        x: 90
+  burnt:
+    weight: 2
+    tags:
+      - burnt
+    gui:
+      item: ia.tfmc_cooking:grilled_mushroom_burnt
+    display:
+      item: ia.tfmc_cooking:grilled_mushroom_burnt
+    display-data:
+      rotation:
+        x: 90
 
 grilled_mushroom:
   cooked:
diff --git a/src/main/resources/naming.yml b/src/main/resources/naming.yml
index 3bb3836..03d487f 100644
--- a/src/main/resources/naming.yml
+++ b/src/main/resources/naming.yml
@@ -17,6 +17,8 @@ dough-fillers:
 # Map ingredient origins to display adjectives in {fillers} names
 origin-adjectives:
   "Spice Leaf": Spiced
+  "Brown Mushroom": Mushroom
+  "Red Mushroom": Mushroom
 
 # Category buckets for station logic
 categories:
diff --git a/src/main/resources/pies.yml b/src/main/resources/pies.yml
index 0f07f67..d424517 100644
--- a/src/main/resources/pies.yml
+++ b/src/main/resources/pies.yml
@@ -81,6 +81,12 @@ fillings:
       - vegetable_chopped_2
       - garnish_chopped
     name: "{origin}"
+  mushroom:
+    # Raw or grilled brown and red mushrooms, baked with the pie; both just "Mushroom".
+    types:
+      - mushroom
+      - grilled_mushroom
+    name: Mushroom
   cheese:
     types:
       - cheese
@@ -102,11 +108,11 @@ kinds:
   Shepherds:
     name: Shepherd's
     layers: [meat, mash]
-    only: [meat, mash, vegetable]
+    only: [meat, mash, vegetable, mushroom]
   Meat:
     name: Meat
     layers: [meat]
-    only: [meat, vegetable]
+    only: [meat, vegetable, mushroom]
   Apple:
     name: Apple
     only-origins: [Apple]
@@ -120,9 +126,22 @@ kinds:
   Mixed: {}
 
 # Tags a pie earns from its fillings, written like sandwiches.yml's bonuses. count: fillings counts the fillings.
+# count: origin counts the foods of that origin, raw or grilled, and hide-at-stars with hide-dish-tags keeps the
+# tag secret in a well-baked pie (see sandwiches.yml).
 bonuses:
   size:
     track: dish_size
     count: fillings
     steps:
       3: hearty
+  # Red mushrooms poison it; in a cooked (not burnt) pie of 3 stars or more, nothing shows.
+  poison:
+    track: poison
+    count: origin
+    origin: Red Mushroom
+    steps:
+      1: tainted
+      2: poisoned
+      3: deadly
+    hide-at-stars: 3
+    hide-dish-tags: [cooked]
diff --git a/src/main/resources/pizzas.yml b/src/main/resources/pizzas.yml
index 26d1d7a..db0b4bd 100644
--- a/src/main/resources/pizzas.yml
+++ b/src/main/resources/pizzas.yml
@@ -35,6 +35,8 @@ bake:
 
 # Tags a pizza earns from its layers, written like sandwiches.yml's bonuses. count: fillings counts the toppings;
 # count: seasonings counts different salts and peppers among them.
+# count: origin counts the foods of that origin, raw or grilled, and hide-at-stars with hide-dish-tags keeps the
+# tag secret on a well-baked pizza (see sandwiches.yml).
 bonuses:
   size:
     track: dish_size
@@ -64,6 +66,17 @@ bonuses:
   sweet_and_savoury:
     track: sweet_and_savoury
     layers: [fruit, sausage]
+  # Red mushrooms poison it; on a cooked (not burnt) pizza of 3 stars or more, nothing shows.
+  poison:
+    track: poison
+    count: origin
+    origin: Red Mushroom
+    steps:
+      1: tainted
+      2: poisoned
+      3: deadly
+    hide-at-stars: 3
+    hide-dish-tags: [cooked]
 
 # Layers, matched by types.yml type, written like sandwiches.yml's:
 #   name         the topping's part of the pizza's name; {origin} is the food's origin (Pineapple)
@@ -146,6 +159,18 @@ toppings:
       - rotten ia.tfmc_cooking:pizza_topping_sausage_rotten
       - burnt ia.tfmc_cooking:pizza_topping_sausage_burnt
       - ia.tfmc_cooking:pizza_topping_sausage
+  mushroom:
+    # Raw or grilled brown and red mushrooms; raw ones bake with the pizza. Both are just "Mushroom" and look alike.
+    types:
+      - mushroom
+      - grilled_mushroom
+    name: Mushroom
+    thickness: 0.05
+    models:
+      - rotten ia.tfmc_cooking:pizza_topping_mushroom_rotten
+      - burnt ia.tfmc_cooking:pizza_topping_mushroom_burnt
+      - cooked ia.tfmc_cooking:pizza_topping_mushroom_cooked
+      - ia.tfmc_cooking:pizza_topping_mushroom
   spice:
     types:
       - spice_1
diff --git a/src/main/resources/pot.yml b/src/main/resources/pot.yml
index 3971871..cd71708 100644
--- a/src/main/resources/pot.yml
+++ b/src/main/resources/pot.yml
@@ -45,3 +45,18 @@ mashes:
     output: mash(type=mashed_potatoes;origin=Potato;tags=freshness.0)
     display: ia.tfmc_cooking:gray
     hint: 4 boiled cut potatoes and a butter
+
+# Tags a soup earns from what's in the pot when it's ladled, written like sandwiches.yml's bonuses. The pot has no
+# layers, so only count: origin applies, over everything in it (pieces, extras and stock).
+bonuses:
+  # Red mushrooms poison it; boiled (mashed, or left to boil, not burnt) into a soup of 3 stars or more, nothing shows.
+  poison:
+    track: poison
+    count: origin
+    origin: Red Mushroom
+    steps:
+      1: tainted
+      2: poisoned
+      3: deadly
+    hide-at-stars: 3
+    hide-tags: [boiled]
diff --git a/src/main/resources/salads.yml b/src/main/resources/salads.yml
index a3c82f4..28b459c 100644
--- a/src/main/resources/salads.yml
+++ b/src/main/resources/salads.yml
@@ -57,6 +57,11 @@ ingredients:
       - sausage
     name: "{origin}"
 
+  mushroom:
+    # Grilled brown and red mushrooms, both just "Mushroom".
+    types:
+      - grilled_mushroom
+    name: Mushroom
 # What goes in unseen and doesn't name the salad.
 #   once            only one food of this extra per salad (one dressing)
 #   returns-bottle  the player keeps the empty glass bottle
@@ -93,6 +98,8 @@ kinds:
 
 # Tags a salad earns from what's in it, written like sandwiches.yml's bonuses. count: fillings counts the ingredients,
 # count: seasonings the different salts and peppers; fresh: true needs everything in it still Fresh.
+# count: origin counts the foods of that origin, and hide-at-stars with hide-tags keeps the tag secret in a
+# well-made salad (see sandwiches.yml).
 bonuses:
   size:
     track: dish_size
@@ -112,3 +119,14 @@ bonuses:
   crisp:
     track: crisp
     fresh: true
+  # Red mushrooms poison it; grilled into a salad of 3 stars or more, nothing shows.
+  poison:
+    track: poison
+    count: origin
+    origin: Red Mushroom
+    steps:
+      1: tainted
+      2: poisoned
+      3: deadly
+    hide-at-stars: 3
+    hide-tags: [cooked]
diff --git a/src/main/resources/sandwiches.yml b/src/main/resources/sandwiches.yml
index 4bd1026..95fb502 100644
--- a/src/main/resources/sandwiches.yml
+++ b/src/main/resources/sandwiches.yml
@@ -23,11 +23,16 @@ output: meal(type=sandwich;origin=Mixed)
 
 # Tags a sandwich earns from its layers, like a soup's Seasoned or Thick (tags.yml tracks; their multipliers apply).
 #   track      the tags.yml track; step picks one of its steps (default: its first)
-#   count      fillings: the number of fillings, with steps giving the step reached at each count
+#   count      fillings: the number of fillings, with steps giving the step reached at each count; origin: the
+#              foods of origin (below) among everything in it, raw or cooked
 #   layers     every one of these layers (ids below); min: each at least this often
 #   any-of     at least one of these layers
 #   valuable   a food types.yml marks valuable (tomato, onion, lettuce, cucumber, corn)
 #   addons     rounded: a chopped garnish and a spice; aromatic: two of one kind
+#   hide-at-stars  the tag is hidden on a dish of at least this many stars: no line in the lore, and the Food and
+#              Nutrition shown leave it out (eating still applies it); with hide-tags, only while every food the
+#              bonus counted is at one of these tag steps (grilled: cooked, not raw or burnt), and with
+#              hide-dish-tags while the dish itself is (a pizza's bake)
 bonuses:
   size:
     track: dish_size
@@ -45,6 +50,17 @@ bonuses:
   flavourful:
     track: flavourful
     valuable: true
+  # Red mushrooms poison it, one more step each; grilled into a sandwich of 3 stars or more, nothing shows.
+  poison:
+    track: poison
+    count: origin
+    origin: Red Mushroom
+    steps:
+      1: tainted
+      2: poisoned
+      3: deadly
+    hide-at-stars: 3
+    hide-tags: [cooked]
 
 # The kind of sandwich its fillings make (the bread doesn't count); the first that matches wins. The sandwich takes
 # the kind as its origin, so types.yml sandwich overrides give each kind its own look and name (a Hamburger). A kind
@@ -157,6 +173,16 @@ layers:
       - rotten ia.tfmc_cooking:sandwich_vegetable_{origin}_rotten
       - ia.tfmc_cooking:sandwich_vegetable_{origin}
       - ia.tfmc_cooking:sandwich_vegetable_lettuce
+  mushroom:
+    # Grilled brown and red mushrooms. Both are just "Mushroom" and look alike, so a red one hides among them.
+    types:
+      - grilled_mushroom
+    name: Mushroom
+    thickness: 0.3
+    models:
+      - rotten ia.tfmc_cooking:sandwich_mushroom_rotten
+      - burnt ia.tfmc_cooking:sandwich_mushroom_burnt
+      - ia.tfmc_cooking:sandwich_mushroom
   meat:
     # Fried (or raw, boiled, burnt) cuts of meat and sausage. Sausage's origin is Mixed.
     types:
diff --git a/src/main/resources/tags.yml b/src/main/resources/tags.yml
index 33cede7..c40d63c 100644
--- a/src/main/resources/tags.yml
+++ b/src/main/resources/tags.yml
@@ -288,3 +288,21 @@ rich:
     value: 0
     food-mult: 1.15
     nutrition-mult: 1.15
+
+# Poison a cook can hide in a dish (bonuses with count: origin, such as red mushrooms in sandwiches.yml), stronger with
+# each one. Food is left alone on purpose: eating moves Nutrition toward the meal's, weighted by the food it gives, so a
+# filling meal with no nutrition drags a diet down hardest. A dish made well (the bonus's hide-at-stars and hide-tags)
+# keeps it secret: no line in the lore, and the Food and Nutrition shown leave it out; eating still applies it.
+poison:
+  tainted:
+    name: "#a3a04aTainted"
+    value: 0
+    nutrition-mult: 0.4
+  poisoned:
+    name: "#7d9a3aPoisoned"
+    value: 1
+    nutrition-mult: 0.15
+  deadly:
+    name: "#6b3a7dDeadly"
+    value: 2
+    nutrition-mult: 0
diff --git a/src/main/resources/types.yml b/src/main/resources/types.yml
index eb62fd3..98a93a3 100644
--- a/src/main/resources/types.yml
+++ b/src/main/resources/types.yml
@@ -681,12 +681,20 @@ mushroom:
   model: brown_mushroom
   food: 6
   nutrition: 4.5
+  mashable: true
   cooking-options:
     frying_pan:
       tag: 1
       time: 12
       burn: 25
       output: vegetable(type=grilled_mushroom)
+    # Boils in the soup pot and mashes into soup like cut veg. raw-in-cookware: it isn't Raw in hand, only once it's
+    # in the pot (as the frying pan's output makes it only Raw in the pan).
+    pot:
+      tag: 3
+      time: 15
+      burn: 30
+      raw-in-cookware: true
   age:
     warmth: 450
     freshness: 7
diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PoisonSoupCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PoisonSoupCoverageTest.java
new file mode 100644
index 0000000..1471b11
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/cooking/PoisonSoupCoverageTest.java
@@ -0,0 +1,156 @@
+package net.tfminecraft.cooking.cooking;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.bukkit.Material;
+import org.bukkit.configuration.MemoryConfiguration;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import net.tfminecraft.cooking.enums.Method;
+import net.tfminecraft.cooking.enums.Tag;
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.cooking.item.model.FoodModel;
+import net.tfminecraft.cooking.item.tag.TagTrack;
+import net.tfminecraft.cooking.loader.TrackLoader;
+import net.tfminecraft.cooking.pot.PotConfig;
+import net.tfminecraft.cooking.quality.CompositionQualityResolver;
+import net.tfminecraft.cooking.quality.CompositionResult;
+
+/**
+ * Mushrooms boil in the soup pot (raw-in-cookware: they aren't Raw in hand), and red ones poison the soup ladled from
+ * it (pot.yml bonuses): hidden in a soup of 3 stars or more while every red mushroom is boiled, shown otherwise.
+ */
+class PoisonSoupCoverageTest {
+    static final String CONFIG = """
+            bonuses:
+              poison:
+                track: poison
+                count: origin
+                origin: Red Mushroom
+                steps:
+                  1: tainted
+                  2: poisoned
+                  3: deadly
+                hide-at-stars: 3
+                hide-tags: [boiled]
+            """;
+
+    CookingReferencesTest.Environment env;
+    PotReference pot;
+    @TempDir Path directory;
+
+    @BeforeEach void open() throws Exception {
+        env = new CookingReferencesTest.Environment();
+        Path file = directory.resolve("pot.yml");
+        Files.writeString(file, CONFIG);
+        new PotConfig().load(file.toFile());
+        pot = new PotReference(env.furniture, Method.POT);
+    }
+
+    @AfterEach void close() {
+        new PotConfig().load(directory.resolve("missing.yml").toFile());
+        env.close();
+    }
+
+    /** A mushroom as types.yml has it: grilled in the pan (an output), boiled in the pot, Raw in neither until in. */
+    static FoodItem mushroom(String origin, boolean rawInCookware) {
+        MemoryConfiguration config = new MemoryConfiguration();
+        config.set("name", "{inherit}");
+        config.set("update", false);
+        config.set("mashable", true);
+        config.set("cooking-options.frying_pan.output", "vegetable(type=grilled_mushroom)");
+        config.set("cooking-options.pot.tag", 3);
+        config.set("cooking-options.pot.time", 2);
+        config.set("cooking-options.pot.burn", 4);
+        config.set("cooking-options.pot.raw-in-cookware", rawInCookware);
+        FoodItem type = new FoodItem("mushroom", config);
+        type.setModel(new FoodModel(new ItemStack(Material.RED_MUSHROOM)));
+        FoodItem food = new FoodItem(type);
+        food.setCategory("vegetable");
+        food.setOrigin(origin);
+        return food;
+    }
+
+    @Test
+    void aMushroomIsOnlyRawOnceItIsInThePotAndThenBoils() {
+        FoodItem fresh = mushroom("Red Mushroom", true);
+        assertFalse(fresh.hasTagTrack("cooked"), "Not Raw in hand");
+        assertFalse(fresh.getCookData().cooksInPlace());
+        CookingOutput.startRaw(fresh, Method.SAUCEPAN);
+        assertFalse(fresh.hasTagTrack("cooked"), "Nothing happens in cookware it has no option for");
+
+        ItemStack held = env.stack(fresh, Material.RED_MUSHROOM, 1);
+        var event = env.addEvent(held);
+        pot.slotAdd(event);
+        assertFalse(event.isCancelled());
+        FoodItem boiling = pot.slots.get("input_1");
+        assertTrue(boiling.hasTag(Tag.RAW));
+        boiling.getCookData().tick();
+        boiling.getCookData().tick();
+        assertEquals("boiled", boiling.getTagTrack("cooked").getCurrentStep().getId());
+
+        // Without raw-in-cookware the pot option would make every mushroom Raw from the start, like cut veg.
+        assertTrue(mushroom("Red Mushroom", false).hasTag(Tag.RAW));
+    }
+
+    /** Red (and brown) mushrooms in the pot, boiled and mashed unless a step is given (raw, burnt). */
+    FoodItem soup(String... mushrooms) {
+        int slot = 1;
+        for (String entry : mushrooms) {
+            String[] parts = entry.split(":");
+            FoodItem piece = env.food("mushroom", "vegetable", parts[0], Method.POT);
+            TagTrack cooked = piece.getTagTrack("cooked");
+            cooked.forceSetValue(parts.length > 1 && parts[1].equals("burnt") ? 2 : 3);
+            if (parts.length == 1) piece.addOrModifyTrack(TrackLoader.getByString("mashed"));
+            pot.slots.put("input_" + slot, piece);
+            env.place("input_" + slot, env.stack(piece, Material.RED_MUSHROOM, 1));
+            slot++;
+        }
+        return ladle();
+    }
+
+    FoodItem ladle() {
+        env.player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER));
+        pot.interact(env.interaction());
+        return env.lastBuilt;
+    }
+
+    static String poison(FoodItem soup) {
+        TagTrack track = soup.getTagTrack("poison");
+        return track == null ? null : track.getCurrentStep().getId() + (track.isHidden() ? " (hidden)" : "");
+    }
+
+    @Test
+    void boiledRedMushroomsHideTheirPoisonInAGoodSoup() {
+        assertEquals("poisoned (hidden)", poison(soup("Red Mushroom", "Red Mushroom", "Brown Mushroom")));
+        // Every serving of that pot is poisoned alike.
+        assertEquals("poisoned (hidden)", poison(ladle()));
+    }
+
+    @Test
+    void aBurntRedMushroomShowsIt() {
+        assertEquals("deadly", poison(soup("Red Mushroom", "Red Mushroom", "Red Mushroom:burnt")));
+    }
+
+    @Test
+    void brownMushroomsAloneMakeAPlainMushroomSoup() {
+        assertNull(poison(soup("Brown Mushroom", "Brown Mushroom")));
+    }
+
+    @Test
+    void aSoupUnderThreeStarsShowsIt() {
+        env.composer.when(() -> CompositionQualityResolver.compose(any(), any(), any())).thenReturn(
+                new CompositionResult(2, 2, Map.of(), List.of(), List.of(), List.of(), null));
+        assertEquals("tainted", poison(soup("Red Mushroom", "Brown Mushroom")));
+    }
+}
diff --git a/src/test/java/net/tfminecraft/cooking/item/tag/HiddenTagCoverageTest.java b/src/test/java/net/tfminecraft/cooking/item/tag/HiddenTagCoverageTest.java
new file mode 100644
index 0000000..30c0824
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/item/tag/HiddenTagCoverageTest.java
@@ -0,0 +1,200 @@
+package net.tfminecraft.cooking.item.tag;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.bukkit.Material;
+import org.bukkit.configuration.MemoryConfiguration;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import net.tfminecraft.cooking.Cooking;
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.cooking.item.model.FoodModel;
+import net.tfminecraft.cooking.loader.FoodLoader;
+import net.tfminecraft.cooking.loader.NamingLoader;
+import net.tfminecraft.cooking.loader.TrackLoader;
+import net.tfminecraft.cooking.quality.QualityConfig;
+import net.tfminecraft.cooking.utils.FoodParser;
+import net.tfminecraft.cooking.utils.InventoryAdder;
+import net.tfminecraft.cooking.utils.ItemBuilder;
+import net.tfminecraft.cooking.utils.Keys;
+import net.tfminecraft.cooking.utils.NameComposer;
+
+/**
+ * A hidden tag (a poison slipped into a dish) is eaten but not shown: the lore leaves out its line, and the stars, Food
+ * and Nutrition it shows leave it out too. It stays hidden through stamping, re-reading, copying, food strings and
+ * names, and a hidden tag doesn't stack with a shown one.
+ */
+class HiddenTagCoverageTest {
+    List tracks;
+    List foods;
+    Cooking previousPlugin;
+    @TempDir Path directory;
+
+    @BeforeEach void open() {
+        MockBukkit.mock();
+        previousPlugin = Cooking.plugin;
+        Cooking.plugin = mock(Cooking.class);
+        when(Cooking.plugin.getName()).thenReturn("Cooking");
+        when(Cooking.plugin.namespace()).thenReturn("cooking");
+        tracks = new ArrayList<>(TrackLoader.oList);
+        foods = new ArrayList<>(FoodLoader.oList);
+        new TrackLoader().load(new File("src/main/resources/tags.yml"));
+        FoodLoader.oList.clear();
+        QualityConfig.apply(1, 5, null, null);
+    }
+
+    @AfterEach void close() throws Exception {
+        Path empty = directory.resolve("empty.yml");
+        Files.writeString(empty, "");
+        new NamingLoader().load(empty.toFile());
+        TrackLoader.oList.clear();
+        TrackLoader.oList.addAll(tracks);
+        FoodLoader.oList.clear();
+        FoodLoader.oList.addAll(foods);
+        Cooking.plugin = previousPlugin;
+        MockBukkit.unmock();
+    }
+
+    /** A 5-star sandwich type with 10 food and 10 nutrition, known to FoodLoader so stacks and strings resolve it. */
+    FoodItem sandwich() {
+        MemoryConfiguration config = new MemoryConfiguration();
+        config.set("name", "Sandwich");
+        config.set("food", 10);
+        config.set("nutrition", 10);
+        config.set("update", false);
+        FoodItem type = new FoodItem("sandwich", config);
+        type.setModel(new FoodModel(new ItemStack(Material.BREAD)));
+        FoodLoader.oList.add(type);
+        FoodItem food = new FoodItem(type);
+        food.setCategory("meal");
+        food.setOrigin("Mixed");
+        food.setQualityRange(5, 5);
+        return food;
+    }
+
+    static TagTrack at(String trackId, String stepId) {
+        TagTrack track = new TagTrack(TrackLoader.getByString(trackId));
+        for (TagStep step : track.getSteps()) {
+            if (step.getId().equals(stepId)) track.forceSetValue((int) step.getRequiredValue());
+        }
+        return track;
+    }
+
+    static FoodItem poisoned(FoodItem food, boolean hidden) {
+        TagTrack poison = at("poison", "poisoned");
+        poison.setHidden(hidden);
+        food.addOrModifyTrack(poison);
+        return food;
+    }
+
+    static boolean anyLine(List lore, String text) {
+        return lore.stream().anyMatch(line -> line.contains(text));
+    }
+
+    @Test
+    void aHiddenTagIsEatenButTheLoreShowsNeitherItsLineNorItsEffect() {
+        FoodItem food = poisoned(sandwich(), true);
+        food.addOrModifyTrack(at("dish_size", "hearty"));
+
+        // Eating counts it: 10 x 1.70 (5 stars) x 0.15 (Poisoned) = 2.6; Food is untouched (x1.8 stars, Hearty x1.1).
+        assertEquals(2.6, food.getFinalNutrition(), 1e-9);
+        assertEquals(19.8, food.getFinalFood(), 1e-9);
+        assertEquals(17.0, food.getShownNutrition(), 1e-9);
+        assertEquals(19.8, food.getShownFood(), 1e-9);
+
+        List lore = ItemBuilder.buildLore(food);
+        assertTrue(anyLine(lore, "Nutrition §f17.0"));
+        assertTrue(anyLine(lore, "Food §f19.8"));
+        assertTrue(anyLine(lore, "Hearty"));
+        assertFalse(anyLine(lore, "Poisoned"));
+        assertFalse(anyLine(lore, "-85% Nutrition"));
+
+        food.getTagTrack("poison").setHidden(false);
+        lore = ItemBuilder.buildLore(food);
+        assertTrue(anyLine(lore, "Nutrition §f2.6"));
+        assertTrue(anyLine(lore, "Poisoned"));
+        assertTrue(anyLine(lore, "-85% Nutrition"));
+    }
+
+    @Test
+    void hiddenStarsAndAHiddenTagOnASauceStayOutOfTheLoreToo() {
+        FoodItem food = sandwich();
+        TagTrack spoiled = new TagTrack("spoiled", false,
+                List.of(new TagStep("spoiled", "Spoiled", 0, 1.0, 1.0, 0, 0, -2)));
+        spoiled.setHidden(true);
+        food.addOrModifyTrack(spoiled);
+        assertEquals(3, food.getEffectiveQuality());
+        assertEquals(5, food.getShownQuality());
+        assertTrue(ItemBuilder.buildLore(food).contains(ItemBuilder.qualityStars(5)));
+        // Eaten at 3 stars (x0.75), shown at 5 (x1.70).
+        assertEquals(7.5, food.getFinalNutrition(), 1e-9);
+        assertEquals(17.0, food.getShownNutrition(), 1e-9);
+
+        FoodItem plain = sandwich();
+        FoodItem sauce = poisoned(sandwich(), true);
+        plain.setSauce(sauce);
+        plain.setSauceName("Gravy");
+        assertEquals(17.0 + 2.6, plain.getFinalNutrition(), 1e-9);
+        assertEquals(34.0, plain.getShownNutrition(), 1e-9);
+        assertTrue(anyLine(ItemBuilder.buildLore(plain), "+17.0 Nutrition"));
+    }
+
+    @Test
+    void stampingRereadingCopyingAndFoodStringsKeepItHidden() {
+        FoodItem food = poisoned(sandwich(), true);
+        ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.PAPER), food, "Mushroom Sandwich");
+        assertEquals("poison",
+                stack.getItemMeta().getPersistentDataContainer().get(Keys.HIDDEN_TAGS, PersistentDataType.STRING));
+        FoodItem read = FoodItem.fromItem(stack);
+        assertTrue(read.getTagTrack("poison").isHidden());
+        assertEquals(List.of("poison"), read.getHiddenTrackIds());
+        assertTrue(new FoodItem(read).getTagTrack("poison").isHidden());
+        // A rebuild (ItemUpdater stamps again from what it read) still hides it.
+        ItemStack rebuilt = ItemBuilder.stamp(stack, read, null);
+        assertFalse(anyLine(rebuilt.getItemMeta().getLore(), "Poisoned"));
+
+        String line = FoodParser.toString(read, 1);
+        assertTrue(line.contains("hidden=poison;"));
+        assertTrue(FoodParser.parse(line).template.getTagTrack("poison").isHidden());
+
+        // Shown again, the stack loses the marker and the food string doesn't write one.
+        read.getTagTrack("poison").setHidden(false);
+        ItemBuilder.stamp(stack, read, null);
+        assertFalse(stack.getItemMeta().getPersistentDataContainer().has(Keys.HIDDEN_TAGS, PersistentDataType.STRING));
+        assertFalse(FoodItem.fromItem(stack).getTagTrack("poison").isHidden());
+        assertFalse(FoodParser.toString(read, 1).contains("hidden="));
+
+        // Hiding a track the food doesn't have does nothing.
+        read.hideTracks(List.of("nowhere"));
+        assertTrue(read.getHiddenTrackIds().isEmpty());
+    }
+
+    @Test
+    void aHiddenTagNeverNamesTheFoodNorStacksWithAShownOne() throws Exception {
+        Path naming = directory.resolve("naming.yml");
+        Files.writeString(naming, "prefix-tracks:\n  - poison\n");
+        new NamingLoader().load(naming.toFile());
+        FoodItem hidden = poisoned(sandwich(), true);
+        FoodItem shown = poisoned(sandwich(), false);
+        assertEquals("", NameComposer.formatPrefixes(hidden));
+        assertTrue(NameComposer.formatPrefixes(shown).endsWith("Poisoned "));
+
+        assertFalse(hidden.sameTags(shown));
+        assertFalse(InventoryAdder.equalsFood(hidden, shown));
+        assertTrue(hidden.sameTags(poisoned(sandwich(), true)));
+        assertTrue(InventoryAdder.equalsFood(hidden, poisoned(sandwich(), true)));
+    }
+}
diff --git a/src/test/java/net/tfminecraft/cooking/sandwich/PoisonBonusCoverageTest.java b/src/test/java/net/tfminecraft/cooking/sandwich/PoisonBonusCoverageTest.java
new file mode 100644
index 0000000..5495d11
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/sandwich/PoisonBonusCoverageTest.java
@@ -0,0 +1,321 @@
+package net.tfminecraft.cooking.sandwich;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.bukkit.Material;
+import org.bukkit.configuration.MemoryConfiguration;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import net.tfminecraft.cooking.Cooking;
+import net.tfminecraft.cooking.item.FoodItem;
+import net.tfminecraft.cooking.item.model.FoodModel;
+import net.tfminecraft.cooking.item.tag.TagStep;
+import net.tfminecraft.cooking.item.tag.TagTrack;
+import net.tfminecraft.cooking.loader.FoodLoader;
+import net.tfminecraft.cooking.loader.NamingLoader;
+import net.tfminecraft.cooking.loader.TrackLoader;
+import net.tfminecraft.cooking.nutrition.DietMath;
+import net.tfminecraft.cooking.quality.CompositionConfig;
+import net.tfminecraft.cooking.quality.QualityConfig;
+import net.tfminecraft.cooking.utils.FoodParser;
+import net.tfminecraft.cooking.utils.ItemBuilder;
+
+/**
+ * Red mushrooms poison a dish (count: origin bonuses): Tainted, Poisoned, then Deadly with each one, raw or grilled. The
+ * poison is hidden when the dish has enough stars and is cooked well (hide-tags on the mushrooms, hide-dish-tags on
+ * the dish's own bake), and shows otherwise. The repo's default configs make a 5-star grilled-mushroom sandwich hide it.
+ */
+class PoisonBonusCoverageTest {
+    static final String POISON = """
+            bonuses:
+              poison:
+                track: poison
+                count: origin
+                origin: Red Mushroom
+                steps:
+                  1: tainted
+                  2: poisoned
+                  3: deadly
+                hide-at-stars: 3
+                hide-tags: [Cooked]
+            """;
+
+    List tracks;
+    List foods;
+    Cooking previousPlugin;
+    @TempDir Path directory;
+    final SandwichLayer bread = layer("bread", "bread_slice");
+    final SandwichLayer mushroom = layer("mushroom", "grilled_mushroom");
+
+    @BeforeEach void open() {
+        MockBukkit.mock();
+        previousPlugin = Cooking.plugin;
+        Cooking.plugin = mock(Cooking.class);
+        when(Cooking.plugin.getName()).thenReturn("Cooking");
+        when(Cooking.plugin.namespace()).thenReturn("cooking");
+        tracks = new ArrayList<>(TrackLoader.oList);
+        foods = new ArrayList<>(FoodLoader.oList);
+        new TrackLoader().load(new File("src/main/resources/tags.yml"));
+        new NamingLoader().load(new File("src/main/resources/naming.yml"));
+        QualityConfig.apply(1, 5, null, null);
+        CompositionConfig.apply(false, Set.of(), null, Set.of(), Map.of(), false, 0, 0, false);
+    }
+
+    @AfterEach void close() throws Exception {
+        new SandwichConfig().load(directory.resolve("missing.yml").toFile());
+        new NamingLoader().load(save("naming.yml", ""));
+        TrackLoader.oList.clear();
+        TrackLoader.oList.addAll(tracks);
+        FoodLoader.oList.clear();
+        FoodLoader.oList.addAll(foods);
+        Cooking.plugin = previousPlugin;
+        MockBukkit.unmock();
+    }
+
+    File save(String name, String yaml) throws Exception {
+        Path file = directory.resolve(name);
+        Files.writeString(file, yaml);
+        return file.toFile();
+    }
+
+    List bonuses(String yaml) throws Exception {
+        return DishBonus.load(YamlConfiguration.loadConfiguration(save("bonuses.yml", yaml)).getConfigurationSection("bonuses"));
+    }
+
+    static SandwichLayer layer(String id, String type) {
+        MemoryConfiguration config = new MemoryConfiguration();
+        config.set("types", List.of(type));
+        return new SandwichLayer(id, config);
+    }
+
+    static FoodItem food(String id, String category, String origin, String... steps) {
+        MemoryConfiguration config = new MemoryConfiguration();
+        config.set("name", id);
+        config.set("update", false);
+        FoodItem food = new FoodItem(id, config);
+        food.setCategory(category);
+        food.setOrigin(origin);
+        for (String step : steps) {
+            String[] at = step.split("\\.");
+            food.addOrModifyTrack(at(at[0], at[1]));
+        }
+        return food;
+    }
+
+    static TagTrack at(String trackId, String stepId) {
+        TagTrack track = new TagTrack(TrackLoader.getByString(trackId));
+        for (TagStep step : track.getSteps()) {
+            if (step.getId().equals(stepId)) track.forceSetValue((int) step.getRequiredValue());
+        }
+        return track;
+    }
+
+    static FoodItem grilled(String origin) {
+        return food("grilled_mushroom", "vegetable", origin, "cooked.cooked");
+    }
+
+    FoodItem dish(int stars, List layers, List bonuses) {
+        FoodItem dish = food("sandwich", "meal", "Mixed");
+        dish.setQualityRange(stars, stars);
+        DishBonus.apply(dish, layers, food -> food.getId().equals("bread_slice") ? bread
+                : food.getId().equals("grilled_mushroom") ? mushroom : null, layer -> layer != bread, bonuses);
+        return dish;
+    }
+
+    static String poison(FoodItem dish) {
+        TagTrack track = dish.getTagTrack("poison");
+        return track == null ? null : track.getCurrentStep().getId() + (track.isHidden() ? " (hidden)" : "");
+    }
+
+    @Test
+    void eachRedMushroomRaisesThePoisonWhateverItIsAndWhereverItLies() throws Exception {
+        List bonuses = bonuses(POISON);
+        List layers = new ArrayList<>(List.of(food("bread_slice", "grain", "Wheat"), grilled("Brown Mushroom")));
+        assertNull(poison(dish(5, layers, bonuses)));
+        layers.add(grilled("Red Mushroom"));
+        assertEquals("tainted (hidden)", poison(dish(5, layers, bonuses)));
+        layers.add(grilled("red mushroom"));
+        assertEquals("poisoned (hidden)", poison(dish(5, layers, bonuses)));
+        // A raw one counts too, even where no layer takes it, and shows the poison: it isn't grilled.
+        layers.add(food("mushroom", "vegetable", "Red Mushroom"));
+        assertEquals("deadly", poison(dish(5, layers, bonuses)));
+        layers.add(grilled("Red Mushroom"));
+        assertEquals("deadly", poison(dish(5, layers, bonuses)));
+        // Deadly takes all of the dish's nutrition, but none of its food.
+        TagStep deadly = dish(5, layers, bonuses).getTagTrack("poison").getCurrentStep();
+        assertEquals(0.0, deadly.getNutritionMultiplier());
+        assertEquals(1.0, deadly.getFoodMultiplier());
+    }
+
+    @Test
+    void thePoisonShowsInADishUnderThreeStarsOrWithABurntMushroom() throws Exception {
+        List bonuses = bonuses(POISON);
+        List grilled = List.of(grilled("Red Mushroom"), grilled("Red Mushroom"));
+        assertEquals("poisoned (hidden)", poison(dish(3, grilled, bonuses)));
+        assertEquals("poisoned", poison(dish(2, grilled, bonuses)));
+        List burnt = List.of(grilled("Red Mushroom"),
+                food("grilled_mushroom", "vegetable", "Red Mushroom", "cooked.burnt"));
+        assertEquals("poisoned", poison(dish(5, burnt, bonuses)));
+        // A burnt brown mushroom gives nothing away: only the red ones have to be grilled.
+        List brown = List.of(grilled("Red Mushroom"),
+                food("grilled_mushroom", "vegetable", "Brown Mushroom", "cooked.burnt"));
+        assertEquals("tainted (hidden)", poison(dish(5, brown, bonuses)));
+    }
+
+    @Test
+    void aBakedDishHidesItWhileTheDishItselfIsCookedAndStarsCountItsBonuses() throws Exception {
+        List bonuses = bonuses("""
+                bonuses:
+                  poison:
+                    track: poison
+                    count: origin
+                    origin: Red Mushroom
+                    steps: {1: tainted}
+                    hide-at-stars: 4
+                    hide-dish-tags: [cooked]
+                  shown:
+                    track: dish_size
+                    count: origin
+                    origin: Red Mushroom
+                    steps: {1: hearty}
+                """);
+        // The pizza's own bake decides, not the raw mushroom it baked.
+        FoodItem cooked = food("pizza_slice", "meal", "Mixed", "cooked.cooked");
+        cooked.setQualityRange(4, 4);
+        DishBonus.apply(cooked, List.of(food("mushroom", "vegetable", "Red Mushroom")), f -> null, l -> true, bonuses);
+        assertEquals("tainted (hidden)", poison(cooked));
+        assertFalse(cooked.getTagTrack("dish_size").isHidden(), "A bonus without hide-at-stars always shows");
+
+        FoodItem burnt = food("pizza_slice", "meal", "Mixed", "cooked.burnt");
+        burnt.setQualityRange(5, 5);
+        DishBonus.apply(burnt, List.of(food("mushroom", "vegetable", "Red Mushroom")), f -> null, l -> true, bonuses);
+        assertEquals("tainted", poison(burnt));
+
+        // Every bonus is in before the stars are judged: a 5-star dish that a bonus takes down to 3 shows it.
+        TrackLoader.oList.add(new TagTrack("soggy", false,
+                List.of(new TagStep("soggy", "Soggy", 0, 1.0, 1.0, 0, 0, -2))));
+        List soggy = new ArrayList<>(bonuses);
+        soggy.addAll(bonuses("""
+                bonuses:
+                  soggy:
+                    track: soggy
+                """));
+        FoodItem pie = food("pie", "meal", "Mixed", "cooked.cooked");
+        pie.setQualityRange(5, 5);
+        DishBonus.apply(pie, List.of(food("mushroom", "vegetable", "Red Mushroom")), f -> null, l -> true, soggy);
+        assertEquals("tainted", poison(pie));
+    }
+
+    @Test
+    void hideTagsJudgeTheFoodsEachKindOfCountCounted() throws Exception {
+        List bonuses = bonuses("""
+                bonuses:
+                  size:
+                    track: dish_size
+                    count: fillings
+                    steps: {1: hearty}
+                    hide-at-stars: 1
+                    hide-tags: [cooked]
+                  seasoning:
+                    track: seasoning
+                    count: seasonings
+                    steps: {1: seasoned}
+                    hide-at-stars: 1
+                    hide-tags: [cooked]
+                  buttered:
+                    track: buttered
+                    hide-at-stars: 1
+                    hide-tags: [cooked]
+                """);
+        SandwichLayer spice = layer("spice", "seasoning_1");
+        FoodItem dish = food("sandwich", "meal", "Mixed");
+        dish.setQualityRange(5, 5);
+        DishBonus.apply(dish, List.of(food("bread_slice", "grain", "Wheat"), grilled("Brown Mushroom"),
+                food("seasoning_1", "salt", "Salt")), f -> switch (f.getId()) {
+                    case "bread_slice" -> bread;
+                    case "grilled_mushroom" -> mushroom;
+                    default -> spice;
+                }, l -> l == mushroom, bonuses);
+        // The filling is grilled; the salt isn't cooked; and an uncounted bonus judges every layer (the bread too).
+        assertTrue(dish.getTagTrack("dish_size").isHidden());
+        assertFalse(dish.getTagTrack("seasoning").isHidden());
+        assertFalse(dish.getTagTrack("buttered").isHidden());
+    }
+
+    /** A food built from its food string, at 5 stars, with the given model so it makes an item. */
+    static ItemStack item(String line) {
+        FoodItem template = FoodParser.parse(line).template;
+        return ItemBuilder.buildSingleWithQuality(template, 5);
+    }
+
+    // The repo's types.yml has no sandwich or bread slice (Dev's config adds them): these are Dev's.
+    static final String DEV_TYPES = """
+            bread_slice:
+              name: "#d9c4a5{prefixes}{fillers}Bread Slice"
+              food: 6.5
+              nutrition: 5
+            sandwich:
+              name: "#e0b878{prefixes}{fillers}Sandwich"
+              food: 20
+              nutrition: 6
+              age:
+                freshness: 1
+            """;
+
+    @Test
+    void theRepoConfigsHideTwoGrilledRedMushroomsInAFiveStarSandwich() throws Exception {
+        new FoodLoader().load(new File("src/main/resources/types.yml"));
+        YamlConfiguration dev = YamlConfiguration.loadConfiguration(save("dev-types.yml", DEV_TYPES));
+        for (String type : dev.getKeys(false)) FoodLoader.oList.add(new FoodItem(type, dev.getConfigurationSection(type)));
+        for (String type : List.of("bread_slice", "grilled_mushroom", "sandwich")) {
+            FoodLoader.getByString(type).setModel(new FoodModel(new ItemStack(Material.PAPER)));
+        }
+        new SandwichConfig().load(new File("src/main/resources/sandwiches.yml"));
+        String bread = "grain(type=bread_slice;origin=Wheat;quality=5;tags=cooked.1:freshness.0)";
+        String red = "vegetable(type=grilled_mushroom;origin=Red Mushroom;quality=5;tags=cooked.1:freshness.0)";
+        ItemStack sandwich = SandwichItems.build(null, List.of(item(bread), item(red), item(red), item(bread)));
+
+        // Layers: 2 x bread 6.5 x 1.55 (Cooked) + 2 x mushroom 6 x 1.55 = 38.75 food, at a weighted nutrition of
+        // (2 x 10.075 x 6.25 + 2 x 9.3 x 5.625) / 38.75 = 5.95. At 5 stars (Food x1.8, Nutrition x1.7) and Fresh
+        // (Nutrition x1.5) it shows 69.8 Food and 15.2 Nutrition; Poisoned (x0.15) makes it 2.3 when eaten.
+        assertEquals("§x§e§0§b§8§7§8Mushroom Sandwich", sandwich.getItemMeta().getDisplayName());
+        List lore = sandwich.getItemMeta().getLore();
+        assertTrue(lore.stream().anyMatch(line -> line.contains("Food §f69.8")), lore.toString());
+        assertTrue(lore.stream().anyMatch(line -> line.contains("Nutrition §f15.2")), lore.toString());
+        assertTrue(lore.stream().anyMatch(line -> line.equals("§7Mushroom")), lore.toString());
+        assertTrue(lore.stream().noneMatch(line -> line.contains("Poison") || line.contains("Red")), lore.toString());
+
+        FoodItem eaten = FoodItem.fromItem(sandwich);
+        assertEquals("poisoned", eaten.getTagTrack("poison").getCurrentStep().getId());
+        assertEquals(69.8, eaten.getFinalFood(), 1e-9);
+        assertEquals(2.3, eaten.getFinalNutrition(), 1e-9);
+        // An eater at Nutrition 30 with room for all 70 food (max food 200): expects 30 + (15.2 - 30) x 70/200 = 25,
+        // gets 30 + (2.3 - 30) x 70/200 = 20.
+        int gained = (int) Math.ceil(eaten.getFinalFood());
+        assertEquals(25, DietMath.lerpRaw(30, eaten.getShownNutrition(), gained, 200, 1.0));
+        assertEquals(20, DietMath.lerpRaw(30, eaten.getFinalNutrition(), gained, 200, 1.0));
+
+        // A burnt red mushroom shows it, and the values with it: a burnt layer feeds 6 x 0.25 at 4.5 x 0.1.
+        String burnt = "vegetable(type=grilled_mushroom;origin=Red Mushroom;quality=5;tags=cooked.2:freshness.0)";
+        List shown = SandwichItems.build(null, List.of(item(bread), item(burnt), item(red), item(bread)))
+                .getItemMeta().getLore();
+        assertTrue(shown.stream().anyMatch(line -> line.contains("Poisoned")), shown.toString());
+        assertTrue(shown.stream().anyMatch(line -> line.contains("Food §f55.7")), shown.toString());
+        assertTrue(shown.stream().anyMatch(line -> line.contains("Nutrition §f2.2")), shown.toString());
+    }
+}