diff --git a/README.md b/README.md index 65424e5..152f27b 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **Working kitchens** — fry, boil, prepare sauces and soups, and bake with cookware, heat sources, and fuelled ovens. Leaving food cooking too long can burn it. - **Food preparation** — milling stones, mixing bowls, butter churns, sausage makers, and baking trays provide different ways to transform ingredients. -- **Sandwiches** — lay a slice of bread on a cutting board, add spreads, cheese and vegetables, close it with another slice and pick it up. Its fillings name it, and its food, quality and freshness come from its layers (`sandwiches.yml`). -- **Pizzas** — place a pizza peel, put dough on it, spread a paste and add cheese, vegetables, fruit, sausage and spices, then carry the peel into a lit oven to bake. A baked pizza is taken off a slice at a time. Its toppings name it, and its food, quality and freshness come from its layers (`pizzas.yml`). +- **Sandwiches** — lay a slice of bread on a cutting board, add spreads, cheese and vegetables, close it with another slice and pick it up. Its fillings name it, and its food, quality and freshness come from its layers (`sandwiches.yml`). Its layers also earn it bonuses such as Hearty, Buttered or Well-Rounded (`bonuses` in `sandwiches.yml`). +- **Pizzas** — place a pizza peel, put dough on it, spread a paste and add cheese, vegetables, fruit, sausage and spices, then carry the peel into a lit oven to bake. A baked pizza is taken off a slice at a time. Its toppings name it, and its food, quality and freshness come from its layers (`pizzas.yml`). Its toppings also earn it bonuses such as Saucy, Extra Cheesy or Well Seasoned (`bonuses` in `pizzas.yml`). - **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/pizza/PizzaConfig.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java index 31aa0fb..4947557 100644 --- a/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java @@ -11,6 +11,7 @@ import org.bukkit.configuration.file.YamlConfiguration; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.DishBonus; import net.tfminecraft.cooking.sandwich.SandwichLayer; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -27,6 +28,7 @@ public final class PizzaConfig { private static SandwichLayer base; private static SandwichLayer sauce; private static List toppings = List.of(); + private static List bonuses = List.of(); public void load(File file) { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); @@ -49,6 +51,7 @@ public void load(File file) { base = layer(config, "base"); sauce = layer(config, "sauce"); toppings = loaded; + bonuses = DishBonus.load(config.getConfigurationSection("bonuses")); } private static SandwichLayer layer(YamlConfiguration config, String id) { @@ -80,6 +83,11 @@ public static boolean isSauce(SandwichLayer layer) { return layer != null && layer == sauce; } + /** The tags a pizza earns from its layers (Saucy, Loaded...). */ + public static List bonuses() { + return bonuses; + } + public static int maxToppings() { return maxToppings; } diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java index 6a62201..c3f92a4 100644 --- a/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java @@ -14,6 +14,7 @@ import net.tfminecraft.cooking.quality.CompositionContext; import net.tfminecraft.cooking.quality.CompositionQualityResolver; import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.sandwich.DishBonus; import net.tfminecraft.cooking.sandwich.SandwichLayer; import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.ItemBuilder; @@ -44,6 +45,8 @@ public static ItemStack build(Player player, List stacks, int baked) CompositionResult composed = CompositionQualityResolver.compose(player, layers, CompositionContext.PIZZA); CompositionApplier.apply(pizza, composed); applyTotals(pizza, layers); + DishBonus.apply(pizza, layers, PizzaConfig::layerFor, + layer -> !PizzaConfig.isBase(layer) && !PizzaConfig.isSauce(layer), PizzaConfig.bonuses()); for (FoodItem food : layers) { SandwichLayer layer = PizzaConfig.layerFor(food); if (layer == null || PizzaConfig.isBase(layer) || PizzaConfig.isSauce(layer)) continue; diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java new file mode 100644 index 0000000..9648656 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java @@ -0,0 +1,140 @@ +package net.tfminecraft.cooking.sandwich; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.bukkit.configuration.ConfigurationSection; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.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. + * + *
+ * bonuses:
+ *   loaded:
+ *     track: dish_size
+ *     count: fillings          # fillings (or toppings), or seasonings: different salts and peppers
+ *     steps: {3: hearty, 5: loaded}
+ *   well_rounded:
+ *     track: well_rounded
+ *     layers: [cheese, vegetable]   # every one of these layers
+ *     any-of: [jam, butter]         # and at least one of these
+ *     min: 2                        # each of the layers above at least this often
+ *     addons: rounded               # a chopped garnish and a spice (aromatic: two of one kind)
+ *     valuable: true                # a food types.yml marks valuable (tomato, onion...)
+ * 
+ * A bonus with no count takes its track's first step, or the one named in step. + */ +public final class DishBonus { + private final String track; + private final String step; + private final List layers = new ArrayList<>(); + private final List anyOf = new ArrayList<>(); + private final int min; + private final String addons; + private final boolean valuable; + private final String count; + private final TreeMap steps = new TreeMap<>(); + + public DishBonus(ConfigurationSection config) { + this.track = config.getString("track", config.getName()); + this.step = config.getString("step"); + for (String id : config.getStringList("layers")) layers.add(id.toLowerCase(Locale.ROOT)); + for (String id : config.getStringList("any-of")) anyOf.add(id.toLowerCase(Locale.ROOT)); + this.min = Math.max(1, config.getInt("min", 1)); + String profile = config.getString("addons"); + this.addons = profile == null ? null : profile.toUpperCase(Locale.ROOT); + this.valuable = config.getBoolean("valuable", false); + this.count = config.getString("count"); + ConfigurationSection counted = config.getConfigurationSection("steps"); + if (counted != null) { + for (String at : counted.getKeys(false)) { + try { + steps.put(Integer.parseInt(at.trim()), counted.getString(at)); + } catch (NumberFormatException ignored) { + // Not a count: skip it. + } + } + } + } + + /** The bonuses in a sandwiches.yml or pizzas.yml bonuses section; none when it's missing. */ + public static List load(ConfigurationSection section) { + List loaded = new ArrayList<>(); + if (section == null) return loaded; + for (String id : section.getKeys(false)) { + ConfigurationSection bonus = section.getConfigurationSection(id); + if (bonus != null) loaded.add(new DishBonus(bonus)); + } + return loaded; + } + + /** + * 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). + */ + public static void apply(FoodItem dish, List foods, Function layerFor, + Predicate filling, List bonuses) { + Map counts = new HashMap<>(); + Map addonsByOrigin = new HashMap<>(); + Set seasonings = new HashSet<>(); + Map all = new HashMap<>(); + int fillings = 0; + 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++; + 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); + } + String profile = StationAddonRules.classifyAddons(addonsByOrigin).name(); + boolean hasValuable = StationAddonRules.hasValuable(all); + for (DishBonus bonus : bonuses) { + int counted = "seasonings".equalsIgnoreCase(bonus.count) ? seasonings.size() : fillings; + String earned = bonus.earned(counts, counted, profile, hasValuable); + if (earned != null) give(dish, bonus.track, earned); + } + } + + /** The step this bonus gives, or null when the layers don't earn it ("" means the track's first step). */ + String earned(Map counts, int counted, String profile, boolean hasValuable) { + for (String layer : layers) { + if (counts.getOrDefault(layer, 0) < min) return null; + } + if (!anyOf.isEmpty() && anyOf.stream().noneMatch(counts::containsKey)) return null; + if (addons != null && !addons.equals(profile)) return null; + if (valuable && !hasValuable) return null; + if (count == null) return step == null ? "" : step; + Map.Entry reached = steps.floorEntry(counted); + return reached == null ? null : reached.getValue(); + } + + private static void give(FoodItem dish, String trackId, String stepId) { + TagTrack template = TrackLoader.getByString(trackId); + if (template == null || template.getSteps().isEmpty()) return; + TagTrack track = new TagTrack(template); + int value = (int) track.getSteps().get(0).getRequiredValue(); + for (TagStep step : track.getSteps()) { + if (step.getId().equalsIgnoreCase(stepId)) value = (int) step.getRequiredValue(); + } + track.forceSetValue(value); + dish.addOrModifyTrack(track); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichConfig.java b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichConfig.java index cb3dd8f..a875cba 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichConfig.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichConfig.java @@ -19,6 +19,7 @@ public final class SandwichConfig { private static int maxFillings = 6; private static String output = "meal(type=sandwich;origin=Mixed)"; private static List layers = List.of(); + private static List bonuses = List.of(); public void load(File file) { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); @@ -36,6 +37,7 @@ public void load(File file) { maxFillings = Math.max(1, config.getInt("max-fillings", 6)); output = config.getString("output", "meal(type=sandwich;origin=Mixed)"); layers = loaded; + bonuses = DishBonus.load(config.getConfigurationSection("bonuses")); } public static boolean isSandwichFurniture(Furniture f) { @@ -54,6 +56,11 @@ public static int maxFillings() { return maxFillings; } + /** The tags a sandwich earns from its layers (Hearty, Buttered...). */ + public static List bonuses() { + return bonuses; + } + /** The finished sandwich's food line (types.yml type, origin, tags). */ public static String output() { return output; diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java index 7e23308..e745245 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java @@ -41,6 +41,7 @@ public static ItemStack build(Player player, List stacks) { CompositionResult composed = CompositionQualityResolver.compose(player, layers, CompositionContext.SANDWICH); CompositionApplier.apply(sandwich, composed); applyTotals(sandwich, layers); + DishBonus.apply(sandwich, layers, SandwichConfig::layerFor, layer -> !layer.isBread(), SandwichConfig.bonuses()); boolean marked = false; for (FoodItem food : layers) { SandwichLayer layer = SandwichConfig.layerFor(food); diff --git a/src/main/resources/pizzas.yml b/src/main/resources/pizzas.yml index 3a02f16..8a0b847 100644 --- a/src/main/resources/pizzas.yml +++ b/src/main/resources/pizzas.yml @@ -33,6 +33,38 @@ bake: cook-seconds: 20 burn-seconds: 60 +# 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. +bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty + 5: loaded + saucy: + track: saucy + layers: [sauce] + extra_cheesy: + track: extra_cheesy + layers: [cheese] + min: 2 + seasoning: + track: seasoning + count: seasonings + steps: + 1: seasoned + 2: well_seasoned + rounded: + track: rounded + addons: rounded + aromatic: + track: aromatic + addons: aromatic + sweet_and_savoury: + track: sweet_and_savoury + layers: [fruit, sausage] + # 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) # names a name per origin instead (goat: Goat Cheese) diff --git a/src/main/resources/sandwiches.yml b/src/main/resources/sandwiches.yml index 2770c1c..aa1d5c8 100644 --- a/src/main/resources/sandwiches.yml +++ b/src/main/resources/sandwiches.yml @@ -21,6 +21,31 @@ max-fillings: 6 # The finished sandwich (types.yml). Food and nutrition are replaced by its layers'. 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 +# 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 +bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty + 5: loaded + buttered: + track: buttered + layers: [butter] + well_rounded: + track: well_rounded + layers: [cheese, vegetable] + any-of: [jam, paste, catsup, mustard, butter] + flavourful: + track: flavourful + valuable: true + # What can go in a sandwich, matched by types.yml type; the first layer that takes a food wins. # bread: true a slice that starts and closes the sandwich (it doesn't name it) # name the filling's part of the sandwich's name; {origin} is the food's origin (Strawberry) diff --git a/src/main/resources/tags.yml b/src/main/resources/tags.yml index 027f11f..9fe4bb3 100644 --- a/src/main/resources/tags.yml +++ b/src/main/resources/tags.yml @@ -220,3 +220,49 @@ reduced: value: 0 nutrition-mult: 1.10 food-mult: 1.10 + +# Sandwich and pizza bonuses (sandwiches.yml / pizzas.yml bonuses). +dish_size: + hearty: + name: "#c08a4bHearty" + value: 0 + food-mult: 1.10 + loaded: + name: "#a0522dLoaded" + value: 1 + food-mult: 1.20 + nutrition-mult: 1.05 + +buttered: + buttered: + name: "#f3e08aButtered" + value: 0 + food-mult: 1.05 + nutrition-mult: 1.10 + +well_rounded: + well_rounded: + name: "#7cb342Well-Rounded" + value: 0 + food-mult: 1.15 + nutrition-mult: 1.15 + +saucy: + saucy: + name: "#c0392bSaucy" + value: 0 + food-mult: 1.10 + +extra_cheesy: + extra_cheesy: + name: "#f1c232Extra Cheesy" + value: 0 + food-mult: 1.15 + nutrition-mult: 1.05 + +sweet_and_savoury: + sweet_and_savoury: + name: "#e07b53Sweet and Savoury" + value: 0 + food-mult: 1.10 + nutrition-mult: 1.10 diff --git a/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java b/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java new file mode 100644 index 0000000..61313d9 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java @@ -0,0 +1,234 @@ +package net.tfminecraft.cooking.sandwich; + +import static org.junit.jupiter.api.Assertions.*; + +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.function.Function; + +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +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.crafting.CraftingStationCoverageTest.Environment; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.NamingLoader; +import net.tfminecraft.cooking.loader.TrackLoader; + +class DishBonusTest { + Environment env; + @TempDir Path directory; + final Map layers = Map.of( + "bread_slice", layer("bread", "bread_slice"), + "butter", layer("butter", "butter"), + "jam", layer("jam", "jam"), + "cheese", layer("cheese", "cheese"), + "vegetable_cut", layer("vegetable", "vegetable_cut"), + "garnish", layer("vegetable", "garnish"), + "fruit", layer("fruit", "fruit"), + "sausage", layer("sausage", "sausage"), + "spice", layer("spice", "spice"), + "paste", layer("sauce", "paste")); + final Function layerFor = food -> layers.get(food.getId()); + + @BeforeEach void open() { + env = new Environment(); + new NamingLoader().load(new File("src/main/resources/naming.yml")); + } + + @AfterEach void close() throws Exception { + new NamingLoader().load(save("naming.yml", "")); + env.close(); + } + + static SandwichLayer layer(String id, String type) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("types", List.of(type)); + return new SandwichLayer(id, config); + } + + 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")); + } + + FoodItem food(String id, String category, String origin, boolean valuable) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", id); + config.set("update", false); + config.set("valuable", valuable); + FoodItem food = new FoodItem(id, config); + food.setCategory(category); + food.setOrigin(origin); + return food; + } + + FoodItem food(String id, String category, String origin) { + return food(id, category, origin, false); + } + + FoodItem dish(List foods, List bonuses) { + FoodItem dish = food("sandwich", "meal", "Mixed"); + DishBonus.apply(dish, foods, layerFor, layer -> !layer.getId().equals("bread") && !layer.getId().equals("sauce"), + bonuses); + return dish; + } + + static String step(FoodItem dish, String track) { + TagTrack tag = dish.getTagTrack(track); + return tag == null ? null : tag.getCurrentStep().getId(); + } + + static final String SANDWICH = """ + bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty + 5: loaded + lots: loaded + buttered: + track: buttered + layers: [butter] + well_rounded: + track: well_rounded + layers: [Cheese, vegetable] + any-of: [Jam, paste, butter] + flavourful: + track: flavourful + valuable: true + plain: 3 + """; + + @Test + void loadingSkipsWhatIsNotABonusAndAMissingSectionGivesNone() throws Exception { + assertTrue(DishBonus.load(null).isEmpty()); + assertEquals(4, bonuses(SANDWICH).size()); + } + + @Test + void sandwichesGrowHeartyThenLoadedWithTheirFillings() throws Exception { + List bonuses = bonuses(SANDWICH); + List foods = new ArrayList<>(List.of(food("bread_slice", "grain", null), food("jam", "jam", "Plum"), + food("jam", "jam", "Plum"), food("bread_slice", "grain", null))); + assertNull(step(dish(foods, bonuses), "dish_size")); + foods.add(1, food("jam", "jam", "Plum")); + assertEquals("hearty", step(dish(foods, bonuses), "dish_size")); + foods.add(1, food("jam", "jam", "Plum")); + foods.add(1, food("stone", "rock", "Stone")); + assertEquals("hearty", step(dish(foods, bonuses), "dish_size")); + foods.add(1, food("jam", "jam", "Plum")); + assertEquals("loaded", step(dish(foods, bonuses), "dish_size")); + } + + @Test + void butterCheeseVegAndValuableVegEarnTheirTags() throws Exception { + List bonuses = bonuses(SANDWICH); + FoodItem plain = dish(List.of(food("cheese", "dairy", "Cow"), food("vegetable_cut", "vegetable", "Carrot")), bonuses); + assertNull(step(plain, "buttered")); + assertNull(step(plain, "well_rounded")); + assertNull(step(plain, "flavourful")); + + FoodItem full = dish(List.of(food("butter", "dairy", "Cow"), food("cheese", "dairy", "Cow"), + food("vegetable_cut", "vegetable", "Tomato", true)), bonuses); + assertEquals("buttered", step(full, "buttered")); + assertEquals("well_rounded", step(full, "well_rounded")); + assertEquals("flavourful", step(full, "flavourful")); + // A filling still names its track's multipliers: Buttered is +5% Food, +10% Nutrition. + assertEquals(1.05, full.getTagTrack("buttered").getCurrentStep().getFoodMultiplier(), 1e-9); + } + + @Test + void pizzasCountCheeseSeasoningsAndGarnishesAgainstSpices() throws Exception { + List bonuses = bonuses(""" + bonuses: + extra_cheesy: + track: extra_cheesy + layers: [cheese] + min: 2 + seasoning: + track: seasoning + count: seasonings + steps: + 1: seasoned + 2: well_seasoned + rounded: + track: rounded + addons: rounded + aromatic: + track: aromatic + addons: aromatic + sweet: + track: sweet_and_savoury + layers: [fruit, sausage] + saucy: + track: saucy + layers: [sauce] + """); + FoodItem one = dish(List.of(food("cheese", "dairy", "Cow"), food("spice", "salt", "Salt"), + food("spice", "salt", "Salt"), food("fruit", "fruit", "Pineapple")), bonuses); + assertNull(step(one, "extra_cheesy")); + assertEquals("seasoned", step(one, "seasoning")); + assertNull(step(one, "rounded")); + assertNull(step(one, "aromatic")); + assertNull(step(one, "sweet_and_savoury")); + assertNull(step(one, "saucy")); + + FoodItem full = dish(List.of(food("paste", "paste", "Tomato"), food("cheese", "dairy", "Cow"), + food("cheese", "dairy", "Goat"), food("spice", "salt", "Salt"), food("spice", "pepper", "Black Pepper"), + food("garnish", "garnish", "Olive"), food("spice", "spice", "Basil"), food("fruit", "fruit", "Pineapple"), + food("sausage", "meat", "Mixed")), bonuses); + assertEquals("extra_cheesy", step(full, "extra_cheesy")); + assertEquals("well_seasoned", step(full, "seasoning")); + assertEquals("rounded", step(full, "rounded")); + assertNull(step(full, "aromatic")); + assertEquals("sweet_and_savoury", step(full, "sweet_and_savoury")); + assertEquals("saucy", step(full, "saucy")); + + // Two basils are one spice; basil and cinnamon are two of a kind. + FoodItem twice = dish(List.of(food("spice", "spice", "Basil"), food("spice", "spice", "Basil")), bonuses); + assertNull(step(twice, "aromatic")); + FoodItem two = dish(List.of(food("spice", "spice", "Basil"), food("spice", "spice", null), + food("garnish", "garnish", "Olive")), bonuses); + assertEquals("rounded", step(two, "rounded")); + FoodItem kinds = dish(List.of(food("spice", "spice", "Basil"), food("spice", "spice", "Cinnamon")), bonuses); + assertEquals("aromatic", step(kinds, "aromatic")); + } + + @Test + void aBonusTakesItsNamedStepAndSkipsTracksThatDoNotExistOrHaveNoSteps() throws Exception { + TrackLoader.oList.add(new TagTrack("empty", false, List.of())); + List bonuses = bonuses(""" + bonuses: + loaded: + track: dish_size + step: loaded + odd: + track: seasoning + step: missing + missing: + track: nowhere + empty: + layers: [] + """); + FoodItem dish = dish(List.of(food("jam", "jam", "Plum")), bonuses); + assertEquals("loaded", step(dish, "dish_size")); + assertEquals("seasoned", step(dish, "seasoning")); + assertNull(dish.getTagTrack("nowhere")); + assertNull(dish.getTagTrack("empty")); + } +}