diff --git a/README.md b/README.md index c8b1d25..3ae119e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **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`). - **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`). - **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.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index e51f42e..9158473 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -66,6 +66,8 @@ import net.tfminecraft.cooking.pizza.PizzaHandler; import net.tfminecraft.cooking.condiment.CondimentConfig; import net.tfminecraft.cooking.condiment.SapTapListener; +import net.tfminecraft.cooking.pie.PieConfig; +import net.tfminecraft.cooking.pie.PieHandler; import net.tfminecraft.cooking.salad.SaladConfig; import net.tfminecraft.cooking.pot.PotConfig; import net.tfminecraft.cooking.salad.SaladHandler; @@ -148,6 +150,8 @@ public class Cooking extends JavaPlugin { private final SaladConfig saladConfig = new SaladConfig(); private final PotConfig potConfig = new PotConfig(); private final SaladHandler saladHandler = new SaladHandler(); + private final PieConfig pieConfig = new PieConfig(); + private final PieHandler pieHandler = new PieHandler(); private final MillingStoneHandler millingStoneHandler = new MillingStoneHandler(); private final TroughHandler troughHandler = new TroughHandler(); private final TroughAging troughAging = new TroughAging(); @@ -276,6 +280,7 @@ public void loadConfigs() { condimentConfig.load(new File(getDataFolder(), "condiments.yml")); saladConfig.load(new File(getDataFolder(), "salads.yml")); potConfig.load(new File(getDataFolder(), "pot.yml")); + pieConfig.load(new File(getDataFolder(), "pies.yml")); millingRecipeLoader.load(new File(getDataFolder(), "milling-recipes.yml")); stationLoader.load(new File(getDataFolder(), "crafting-stations.yml")); trackLoader.load(new File(getDataFolder(), "tags.yml")); @@ -343,6 +348,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(pizzaHandler, this); getServer().getPluginManager().registerEvents(new SapTapListener(), this); getServer().getPluginManager().registerEvents(saladHandler, this); + getServer().getPluginManager().registerEvents(pieHandler, this); getServer().getPluginManager().registerEvents(millingStoneHandler, this); getServer().getPluginManager().registerEvents(troughHandler, this); getServer().getPluginManager().registerEvents(ovenLifecycleHandler, this); @@ -441,6 +447,7 @@ public void createConfigs() { "condiments.yml", "salads.yml", "pot.yml", + "pies.yml", "milling-recipes.yml", "cookware.yml", "tags.yml", diff --git a/src/main/java/net/tfminecraft/cooking/crafting/CraftingRecipe.java b/src/main/java/net/tfminecraft/cooking/crafting/CraftingRecipe.java index a0615fb..ced36c6 100644 --- a/src/main/java/net/tfminecraft/cooking/crafting/CraftingRecipe.java +++ b/src/main/java/net/tfminecraft/cooking/crafting/CraftingRecipe.java @@ -20,6 +20,8 @@ public class CraftingRecipe { private int yield; /** The output is the input cut differently: it keeps the input's origin, fillers and tags. One input at a time. */ private boolean inherit; + /** With inherit, each output takes an even share of its input's food (a pie's slices hold what the pie did). */ + private boolean shareFood; /** On a station with fill-models, its inputs show as the rising fill. False keeps them as themselves (olives in the cheese press). */ private boolean fill; @@ -32,6 +34,7 @@ public CraftingRecipe(String id, ConfigurationSection config) { this.keepOutputAge = config.getBoolean("keep-output-age", false); this.yield = Math.max(1, config.getInt("yield", 1)); this.inherit = config.getBoolean("inherit", false); + this.shareFood = config.getBoolean("share-food", false); this.fill = config.getBoolean("fill", true); } @@ -66,6 +69,9 @@ public int getYield() { public boolean inherits() { return inherit; } + public boolean sharesFood() { + return shareFood; + } public boolean fills() { return fill; } diff --git a/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java index dde1d86..c35f1c2 100644 --- a/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java @@ -374,7 +374,10 @@ private Built build(Player p, CraftingRecipe recipe, List inputs, Stri Map ownAge = recipe.keepsOutputAge() ? ageOf(item) : null; CompositionApplier.apply(item, composed); if (ownAge != null) restoreAge(item, ownAge); - if (recipe.inherits() && !inputs.isEmpty()) inherit(item, inputs.get(0)); + if (recipe.inherits() && !inputs.isEmpty()) { + inherit(item, inputs.get(0)); + if (recipe.sharesFood()) share(item, inputs.get(0), recipe.getYield()); + } ItemStack output = ItemBuilder.buildSingleWithQuality(item, null, composed.getFinalQuality()); if (output == null || output.getType().isAir()) return null; return new Built(item, output); @@ -521,6 +524,12 @@ private static void inherit(FoodItem output, FoodItem input) { } } + /** A pie's slice holds its share of the pie: its food over the slices it's cut into, at the pie's nutrition. */ + private static void share(FoodItem output, FoodItem input, int pieces) { + output.setBaseFood(input.getBaseFood() / pieces); + output.setBaseNutrition(input.getBaseNutrition()); + } + private static Map ageOf(FoodItem item) { Map age = new HashMap<>(); for (TagTrack track : item.getTagTracks()) { diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java index 0ed0456..c7c8192 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java @@ -6,6 +6,8 @@ import net.tfminecraft.cooking.baking.BakingTrayRegistry; import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.pie.PieConfig; +import net.tfminecraft.cooking.pie.PieTin; import net.tfminecraft.cooking.pizza.PizzaConfig; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotFurnitureAddEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -27,8 +29,13 @@ public void onFurnitureAdd(FurnitureSlotFurnitureAddEvent event) { if (nested == null || BakingTrayRegistry.isTray(nested) || PizzaConfig.isPeel(nested)) { return; } + String refused = PieConfig.isTin(nested) ? PieTin.ovenRefusal(nested) + : "§cOnly baking trays, pie tins and pizza peels can go in the oven."; + if (refused == null) { + return; + } event.setCancelled(true); - event.getPlayer().sendMessage("§cOnly baking trays and pizza peels can go in the oven."); + event.getPlayer().sendMessage(refused); } } diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java index c401ca3..b1da9a6 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java @@ -14,6 +14,8 @@ import net.tfminecraft.cooking.heat.HeatSources; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.pie.PieBaking; +import net.tfminecraft.cooking.pie.PieConfig; import net.tfminecraft.cooking.pizza.PizzaBaking; import net.tfminecraft.cooking.pizza.PizzaConfig; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -52,6 +54,10 @@ private void tick() { PizzaBaking.tick(consumer, tray); continue; } + if (PieConfig.isTin(tray)) { + PieBaking.tick(consumer, tray); + continue; + } BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(tray); if (recipe == null) { continue; diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieBaking.java b/src/main/java/net/tfminecraft/cooking/pie/PieBaking.java new file mode 100644 index 0000000..30dfba5 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pie/PieBaking.java @@ -0,0 +1,26 @@ +package net.tfminecraft.cooking.pie; + +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** + * Bakes the pie in a tin in an oven, one second at a time while the oven is hot: it cooks after pies.yml's + * cook-seconds and burns at burn-seconds, and its crust then shows cooked or burnt. Only a filled pie bakes. + */ +public final class PieBaking { + + private PieBaking() {} + + public static void tick(Furniture oven, Furniture tin) { + int baked = PieTin.baked(tin); + if (baked >= PieTin.BURNT || !PieTin.ready(tin) || !HeatSources.consumerHasHeat(oven)) return; + int elapsed = PieTin.elapsed(tin) + 1; + int next = baked; + if (elapsed >= PieConfig.burnSeconds()) next = PieTin.BURNT; + else if (elapsed >= PieConfig.cookSeconds()) next = PieTin.COOKED; + PieTin.setBake(tin, elapsed, next); + if (next != baked) PieTin.remodel(tin); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(tin); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieConfig.java b/src/main/java/net/tfminecraft/cooking/pie/PieConfig.java new file mode 100644 index 0000000..2aa8742 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pie/PieConfig.java @@ -0,0 +1,139 @@ +package net.tfminecraft.cooking.pie; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.salad.SaladKind; +import net.tfminecraft.cooking.sandwich.DishBonus; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** + * pies.yml: the tins pies are baked in, the dough that lines them, the fillings they take, the kinds of pie those make, + * how long they bake and the bonuses they earn. + */ +public final class PieConfig { + private static Set furniture = Set.of(); + private static String slot = "pie"; + private static int minFillings = 1; + private static int maxFillings = 3; + private static String output = "meal(type=pie;origin=Mixed)"; + private static int cookSeconds = 5; + private static int burnSeconds = 10; + private static SandwichLayer crust; + private static List fillings = List.of(); + private static List kinds = List.of(); + private static List bonuses = List.of(); + + public void load(File file) { + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + Set tins = new HashSet<>(); + for (String id : config.getStringList("furniture")) tins.add(id.toLowerCase(Locale.ROOT)); + furniture = tins; + slot = config.getString("slot", "pie"); + maxFillings = Math.max(1, config.getInt("max-fillings", 3)); + minFillings = Math.max(1, Math.min(maxFillings, config.getInt("min-fillings", 1))); + output = config.getString("output", "meal(type=pie;origin=Mixed)"); + cookSeconds = Math.max(1, config.getInt("bake.cook-seconds", 5)); + burnSeconds = Math.max(cookSeconds + 1, config.getInt("bake.burn-seconds", 10)); + ConfigurationSection crustSection = config.getConfigurationSection("crust"); + crust = crustSection == null ? null : new SandwichLayer("crust", crustSection); + List loaded = new ArrayList<>(); + ConfigurationSection section = config.getConfigurationSection("fillings"); + if (section != null) { + for (String id : section.getKeys(false)) { + ConfigurationSection filling = section.getConfigurationSection(id); + if (filling != null) loaded.add(new SandwichLayer(id, filling)); + } + } + fillings = loaded; + List kindsLoaded = new ArrayList<>(); + ConfigurationSection kindSection = config.getConfigurationSection("kinds"); + if (kindSection != null) { + for (String origin : kindSection.getKeys(false)) { + ConfigurationSection kind = kindSection.getConfigurationSection(origin); + if (kind != null) kindsLoaded.add(new SaladKind(origin, kind)); + } + } + kinds = kindsLoaded; + bonuses = DishBonus.load(config.getConfigurationSection("bonuses")); + } + + public static boolean isTin(Furniture f) { + return f != null && f.getType() != null && furniture.contains(f.getType().getId().toLowerCase(Locale.ROOT)); + } + + /** The tin's slot the dough lies in, showing the pie. */ + public static String slot() { + return slot; + } + + /** The dough that lines the tin, or null when this food isn't it. */ + public static SandwichLayer crustFor(FoodItem food) { + return crust != null && crust.matches(food) ? crust : null; + } + + /** The dough lining the tin, not a filling. */ + public static boolean isCrust(SandwichLayer layer) { + return layer != null && layer == crust; + } + + /** The filling this food makes, or null when it doesn't go in a pie. */ + public static SandwichLayer fillingFor(FoodItem food) { + for (SandwichLayer filling : fillings) { + if (filling.matches(food)) return filling; + } + return null; + } + + /** The crust or filling this food makes, or null. */ + public static SandwichLayer layerFor(FoodItem food) { + SandwichLayer layer = crustFor(food); + return layer != null ? layer : fillingFor(food); + } + + /** The first kind these fillings make, or null when none takes them. */ + public static SaladKind kindFor(List foods, List made) { + for (SaladKind kind : kinds) { + if (kind.matches(foods, made)) return kind; + } + return null; + } + + /** Fillings a pie needs before it bakes. */ + public static int minFillings() { + return minFillings; + } + + public static int maxFillings() { + return maxFillings; + } + + /** The whole pie (types.yml type, origin, tags); its food and nutrition are replaced by what's in it. */ + public static String output() { + return output; + } + + /** Seconds in a hot oven until the pie is cooked. */ + public static int cookSeconds() { + return cookSeconds; + } + + /** Seconds in a hot oven, counted from when it went in, until the pie burns. */ + public static int burnSeconds() { + return burnSeconds; + } + + /** The tags a pie earns from its fillings (Hearty...). */ + public static List bonuses() { + return bonuses; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieHandler.java b/src/main/java/net/tfminecraft/cooking/pie/PieHandler.java new file mode 100644 index 0000000..8c84eae --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pie/PieHandler.java @@ -0,0 +1,184 @@ +package net.tfminecraft.cooking.pie; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +/** + * Makes pies in a pie tin (pies.yml), the round cousin of the bread tray. Dough on an empty tin lines it, then fillings + * go in under its top crust; an empty hand takes back what went in last. Carried into a lit oven (sneak and + * right-click with an empty hand, like a baking tray) a filled pie bakes, and once it's baked an empty hand takes the + * whole pie out, to be cut into slices on the cutting board. + */ +public final class PieHandler implements Listener { + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInteract(FurnitureInteractEvent event) { + Furniture f = event.getFurniture(); + if (!PieConfig.isTin(f)) return; + Player p = event.getPlayer(); + ItemStack hand = p.getInventory().getItemInMainHand(); + boolean emptyHand = hand == null || hand.getType().isAir(); + // Sneaking with an empty hand carries the tin, into the oven or out of it. + if (emptyHand && p.isSneaking()) return; + PlacedSlot crust = PieTin.crust(f); + List fillings = PieTin.fillings(f); + int baked = PieTin.baked(f); + + if (emptyHand) { + // An empty tin is picked up with an empty hand. + if (crust == null) return; + if (baked > PieTin.RAW) { + event.setCancelled(true); + takePie(p, f, crust, fillings, baked); + } else if (!f.isAttached()) { + event.setCancelled(true); + takeLast(p, f, crust, fillings); + } + return; + } + + FoodItem food = FoodItem.fromItem(hand); + if (food == null) return; + event.setCancelled(true); + SandwichLayer layer = PieConfig.layerFor(food); + if (f.isAttached()) { + p.sendMessage("§7Take the tin out of the oven first."); + } else if (crust == null) { + if (PieConfig.isCrust(layer)) line(p, f, hand); + else p.sendMessage("§7Line the tin with a piece of dough first."); + } else if (baked > PieTin.RAW) { + p.sendMessage("§7This pie is baked; take it out with an empty hand."); + } else if (layer == null) { + p.sendMessage("§7That doesn't go in a pie."); + } else if (PieConfig.isCrust(layer)) { + p.sendMessage("§7There's already dough in this tin."); + } else if (fillings.size() >= PieConfig.maxFillings()) { + p.sendMessage("§7The pie is full; bake it in the oven."); + } else { + fill(p, f, hand, fillings); + } + } + + /** A broken tin drops its baked pie whole, or its dough and fillings when it hasn't baked. */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBreak(FurnitureBreakEvent event) { + Furniture f = event.getFurniture(); + if (!PieConfig.isTin(f)) return; + PlacedSlot crust = PieTin.crust(f); + if (crust == null) return; + List fillings = PieTin.fillings(f); + int baked = PieTin.baked(f); + ItemStack pie = baked > PieTin.RAW ? PieItems.build(event.getPlayer(), crust.getCurrentItem(), fillings, baked) : null; + List drops = new ArrayList<>(); + if (pie != null) { + drops.add(pie); + } else { + drops.add(asHeld(crust.getCurrentItem())); + drops.addAll(fillings); + } + clear(f, crust); + for (ItemStack drop : drops) f.getLoc().getWorld().dropItem(f.getLoc(), drop); + } + + private static void line(Player p, Furniture f, ItemStack hand) { + PlacedSlot slot = f.getOrCreatePlacedSlot(PieConfig.slot()); + slot.forceModel(one(hand)); + PieTin.setBake(f, 0, PieTin.RAW); + PieTin.remodel(f); + use(p, hand); + done(p, f, Sound.ENTITY_ITEM_FRAME_ADD_ITEM); + } + + private static void fill(Player p, Furniture f, ItemStack hand, List fillings) { + fillings.add(one(hand)); + PieTin.setFillings(f, fillings); + // A new filling has to bake through with the rest. + PieTin.setBake(f, 0, PieTin.RAW); + PieTin.remodel(f); + use(p, hand); + done(p, f, Sound.BLOCK_HONEY_BLOCK_PLACE); + } + + /** Takes back what went in last: the last filling, or with none, the dough. */ + private static void takeLast(Player p, Furniture f, PlacedSlot crust, List fillings) { + ItemStack back; + if (!fillings.isEmpty()) { + back = fillings.remove(fillings.size() - 1); + PieTin.setFillings(f, fillings); + PieTin.remodel(f); + } else { + back = asHeld(crust.getCurrentItem()); + clear(f, crust); + } + give(p, back); + done(p, f, Sound.ENTITY_ITEM_FRAME_REMOVE_ITEM); + } + + /** Takes the baked pie out whole, leaving the tin empty. */ + private static void takePie(Player p, Furniture f, PlacedSlot crust, List fillings, int baked) { + ItemStack pie = PieItems.build(p, crust.getCurrentItem(), fillings, baked); + if (pie == null) { + p.sendMessage("§cThis pie can't be made right now."); + return; + } + clear(f, crust); + give(p, pie); + done(p, f, Sound.ENTITY_ITEM_PICKUP); + Bukkit.getPluginManager().callEvent(new DishCookedEvent(p, pie, "pie")); + } + + private static ItemStack one(ItemStack hand) { + ItemStack one = hand.clone(); + one.setAmount(1); + return one; + } + + private static void use(Player p, ItemStack hand) { + hand.setAmount(hand.getAmount() - 1); + p.getInventory().setItemInMainHand(hand); + } + + private static void done(Player p, Furniture f, Sound sound) { + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); + f.getLoc().getWorld().playSound(f.getLoc(), sound, 1f, 1f); + p.swingMainHand(); + } + + private static void clear(Furniture f, PlacedSlot crust) { + crust.clearModel(); + f.removeActiveSlot(crust.getId()); + PieTin.clear(f); + } + + private static void give(Player p, ItemStack item) { + ItemStack rest = InventoryAdder.addItem(p, item); + if (rest != null) p.getWorld().dropItem(p.getLocation(), rest); + } + + /** The dough as it looks in hand, keeping its food data. */ + private static ItemStack asHeld(ItemStack item) { + FoodItem food = FoodItem.fromItem(item); + if (food == null) return item; + ItemStack held = ItemUpdater.applyItemUpdate(item.clone(), food, null); + return held == null ? item : held; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieItems.java b/src/main/java/net/tfminecraft/cooking/pie/PieItems.java new file mode 100644 index 0000000..67f7b46 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pie/PieItems.java @@ -0,0 +1,95 @@ +package net.tfminecraft.cooking.pie; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.CompositionApplier; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionFreshnessApplier; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.salad.SaladKind; +import net.tfminecraft.cooking.sandwich.DishBonus; +import net.tfminecraft.cooking.sandwich.SandwichItems; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.WarmthUtils; + +/** Turns the dough and fillings in a tin into one whole pie. */ +public final class PieItems { + private static final String COOKED_TRACK = "cooked"; + + private PieItems() {} + + /** + * The pie this dough and these fillings make at this bake (PieTin.COOKED or BURNT). Its food is what they all hold, + * summed like a sandwich's but with the pie's bake in place of each one's own cooking; its quality, freshness and + * variety come from all of them. Its kind (pies.yml kinds) gives it its origin and look, and its name: the kind's + * name (Key Lime Pie), or else its fillings (Carrot and Onion Pie). It comes out hot. Null when pies.yml's output + * makes no item. + */ + public static ItemStack build(Player player, ItemStack dough, List fillings, int baked) { + List all = new ArrayList<>(); + FoodItem crust = food(dough); + if (crust != null) all.add(crust); + List filled = new ArrayList<>(); + for (ItemStack stack : fillings) { + FoodItem food = food(stack); + if (food != null) filled.add(food); + } + all.addAll(filled); + FoodParser.Result parsed = FoodParser.parse(PieConfig.output()); + if (all.isEmpty() || parsed == null || parsed.template == null) return null; + FoodItem pie = parsed.template; + CompositionResult composed = CompositionQualityResolver.compose(player, all, CompositionContext.PIE); + 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); + } + WarmthUtils.applyHot(pie); + return ItemBuilder.buildComposedWithQuality(pie, composed.getFinalQuality()); + } + + /** Its kind sets its origin, and its name or, without one, the fillings' names. */ + private static void name(FoodItem pie, List fillings) { + List named = new ArrayList<>(); + List made = new ArrayList<>(); + for (FoodItem food : fillings) { + SandwichLayer layer = PieConfig.fillingFor(food); + if (layer == null) continue; + named.add(food); + made.add(layer); + } + SaladKind kind = PieConfig.kindFor(named, made); + if (kind != null) pie.setOrigin(kind.getOrigin()); + if (kind != null && kind.getName() != null) { + pie.addIngredient(kind.getName()); + return; + } + for (int i = 0; i < named.size(); i++) { + String name = made.get(i).fillerName(named.get(i)); + if (!name.isEmpty()) pie.addIngredient(name); + } + } + + private static FoodItem food(ItemStack stack) { + FoodItem food = FoodItem.fromItem(stack); + // A layer ages in the tin as it would anywhere else. + if (food != null) food.updateAge(); + return food; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pie/PieTin.java b/src/main/java/net/tfminecraft/cooking/pie/PieTin.java new file mode 100644 index 0000000..c9887b3 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pie/PieTin.java @@ -0,0 +1,116 @@ +package net.tfminecraft.cooking.pie; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.cooking.PotReference; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +/** + * A pie in its tin: the dough lining it in the tin's pie slot (which shows the whole pie), the fillings under its top + * crust, unseen, and how far it has baked. The fillings and the bake are kept in the tin's variables, so they survive + * restarts and trips in and out of the oven. + */ +public final class PieTin { + static final String FILLINGS = "pie.fillings"; + static final String ELAPSED = "pie.elapsed"; + static final String BAKED = "pie.baked"; + public static final int RAW = 0; + public static final int COOKED = 1; + public static final int BURNT = 2; + /** Counts as one of the dough's tags while fillings lie under its top crust. */ + static final String FILLED = "filled"; + private static final List STATE_TAGS = List.of("raw", "cooked", "burnt"); + private static final String SEPARATOR = ","; + + private PieTin() {} + + /** The tin's pie slot while dough lies in it, or null. */ + static PlacedSlot crust(Furniture f) { + PlacedSlot slot = f.getActiveSlot(PieConfig.slot()).orElse(null); + return slot != null && slot.getCurrentItem() != null && !slot.getCurrentItem().getType().isAir() ? slot : null; + } + + /** The fillings under the crust, first in first. */ + static List fillings(Furniture f) { + List fillings = new ArrayList<>(); + if (!(f.getVariables().get(FILLINGS) instanceof String encoded)) return fillings; + for (String part : encoded.split(SEPARATOR)) { + ItemStack stack = PotReference.decodeStack(part); + if (stack != null) fillings.add(stack); + } + return fillings; + } + + static void setFillings(Furniture f, List fillings) { + if (fillings.isEmpty()) { + f.getVariables().remove(FILLINGS); + return; + } + List parts = new ArrayList<>(); + for (ItemStack stack : fillings) parts.add(PotReference.encodeStack(stack)); + f.getVariables().put(FILLINGS, String.join(SEPARATOR, parts)); + } + + /** RAW, COOKED or BURNT. */ + public static int baked(Furniture f) { + return Math.max(RAW, Math.min(BURNT, number(f, BAKED))); + } + + /** Seconds the pie has spent in a hot oven. */ + static int elapsed(Furniture f) { + return number(f, ELAPSED); + } + + private static int number(Furniture f, String key) { + return f.getVariables().get(key) instanceof Number n ? n.intValue() : 0; + } + + static void setBake(Furniture f, int elapsed, int baked) { + f.getVariables().put(ELAPSED, elapsed); + f.getVariables().put(BAKED, baked); + } + + /** Forgets the pie: its fillings and its bake. */ + static void clear(Furniture f) { + f.getVariables().remove(FILLINGS); + f.getVariables().remove(ELAPSED); + f.getVariables().remove(BAKED); + } + + /** Lined with dough and filled enough to bake. */ + public static boolean ready(Furniture f) { + return crust(f) != null && fillings(f).size() >= PieConfig.minFillings(); + } + + /** + * Why this tin can't go in an oven, or null when it can: an empty tin can, and so can a pie with its fillings; + * dough alone can't. + */ + public static String ovenRefusal(Furniture f) { + if (crust(f) == null || ready(f)) return null; + return "§cFill the pie before baking it."; + } + + /** + * Shows the dough as the pie looks at its bake (pies.yml crust models): raw, cooked or burnt counts as one of its + * tags, and filled too while fillings lie under it. Nothing changes when no model exists. + */ + static void remodel(Furniture f) { + PlacedSlot slot = crust(f); + if (slot == null) return; + ItemStack item = slot.getCurrentItem(); + FoodItem food = FoodItem.fromItem(item); + SandwichLayer crust = PieConfig.crustFor(food); + if (crust == null) return; + List tags = new ArrayList<>(List.of(STATE_TAGS.get(baked(f)))); + if (!fillings(f).isEmpty()) tags.add(FILLED); + ItemStack shown = crust.model(food, item, tags); + if (shown != null) slot.forceModel(shown); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java index 1ea2d0a..96fd1ae 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java +++ b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java @@ -12,6 +12,7 @@ public enum CompositionContext { SANDWICH(true), PIZZA(true), SALAD(true), + PIE(true), MILLING(false), BAKING(false); diff --git a/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java b/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java index 5ead9be..b3465c1 100644 --- a/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java +++ b/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java @@ -12,8 +12,9 @@ import net.tfminecraft.cooking.sandwich.SandwichLayer; /** - * One kind of dish from salads.yml or sandwiches.yml kinds (a Greek salad, a Hamburger): what its ingredients must be. - * A dish of this kind takes the kind's name as its origin, so types.yml overrides give it its own look and name. + * One kind of dish from salads.yml, sandwiches.yml or pies.yml kinds (a Greek salad, a Hamburger, a Key Lime Pie): + * what its ingredients must be. A dish of this kind takes the kind's name as its origin, so types.yml overrides give it + * its own look and name. * *
  * Greek:
@@ -21,9 +22,12 @@
  *   only: [fruit]               # nothing but these layers
  *   origins: [Olive]            # every one of these origins
  *   any-origin: [Tomato]        # at least one of these
+ *   only-origins: [Apple]       # nothing but these origins
+ *   min-origins: 2              # at least this many different origins
  *   mostly: Carrot              # at least half the ingredients are this
  *   tags:                       # every food of a layer has one of its tag steps (fried meat, not raw)
  *     meat: [cooked]
+ *   name: Key Lime              # what the dish is called by instead of its ingredients (Key Lime Pie)
  * 
* A kind with no rules takes any dish, so the last one catches the rest. */ @@ -33,8 +37,11 @@ public final class SaladKind { private final List only = new ArrayList<>(); private final List origins = new ArrayList<>(); private final List anyOrigin = new ArrayList<>(); + private final List onlyOrigins = new ArrayList<>(); + private final int minOrigins; private final String mostly; private final Map> tags = new HashMap<>(); + private final String name; public SaladKind(String origin, ConfigurationSection config) { this.origin = origin; @@ -42,6 +49,8 @@ public SaladKind(String origin, ConfigurationSection config) { lower(config.getStringList("only"), only); lower(config.getStringList("origins"), origins); lower(config.getStringList("any-origin"), anyOrigin); + lower(config.getStringList("only-origins"), onlyOrigins); + this.minOrigins = config.getInt("min-origins", 0); String most = config.getString("mostly"); this.mostly = most == null ? null : most.toLowerCase(Locale.ROOT); ConfigurationSection tagged = config.getConfigurationSection("tags"); @@ -52,6 +61,7 @@ public SaladKind(String origin, ConfigurationSection config) { tags.put(layer.toLowerCase(Locale.ROOT), steps); } } + this.name = config.getString("name"); } private static void lower(List from, List to) { @@ -63,6 +73,11 @@ public String getOrigin() { return origin; } + /** What a dish of this kind is called by in place of its ingredients (Key Lime), or null to name it by them. */ + public String getName() { + return name; + } + /** Whether these ingredients, with the layers they make, are this kind of dish. */ public boolean matches(List foods, List made) { Map byOrigin = new HashMap<>(); @@ -78,6 +93,8 @@ public boolean matches(List foods, List made) { if (!layerIds.containsAll(layers)) return false; if (!byOrigin.keySet().containsAll(origins)) return false; if (!anyOrigin.isEmpty() && anyOrigin.stream().noneMatch(byOrigin::containsKey)) return false; + if (!onlyOrigins.isEmpty() && !onlyOrigins.containsAll(byOrigin.keySet())) return false; + if (byOrigin.size() < minOrigins) return false; return mostly == null || byOrigin.getOrDefault(mostly, 0) * 2 >= foods.size(); } diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java index d1e0f0c..14ca29b 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java @@ -20,6 +20,7 @@ /** Turns the layers on a board into one sandwich. */ public final class SandwichItems { + private static final String COOKED_TRACK = "cooked"; private SandwichItems() {} @@ -70,22 +71,30 @@ public static ItemStack build(Player player, List stacks) { * eating them one after another would give. */ public static void applyTotals(FoodItem sandwich, List layers) { + applyTotals(sandwich, layers, false); + } + + /** + * As {@link #applyTotals(FoodItem, List)}; bakedTogether leaves out each layer's own cooking, for a dish that is + * baked as a whole (a pie's raw meat cooks with it), so its bake applies to all of it once. + */ + public static void applyTotals(FoodItem dish, List layers, boolean bakedTogether) { double food = 0; double nutrition = 0; for (FoodItem layer : layers) { - double part = layer.getBaseFood() * kept(layer, true); + double part = layer.getBaseFood() * kept(layer, true, bakedTogether); food += part; - nutrition += part * layer.getBaseNutrition() * kept(layer, false); + nutrition += part * layer.getBaseNutrition() * kept(layer, false, bakedTogether); } - sandwich.setBaseFood(food); - if (food > 0) sandwich.setBaseNutrition(nutrition / food); + dish.setBaseFood(food); + if (food > 0) dish.setBaseNutrition(nutrition / food); } /** What a layer's lasting tags (burnt bread) do to it; its age becomes the sandwich's own freshness instead. */ - private static double kept(FoodItem layer, boolean forFood) { + private static double kept(FoodItem layer, boolean forFood, boolean bakedTogether) { double multiplier = 1; for (TagTrack track : layer.getTagTracks()) { - if (track.isAgeable()) continue; + if (track.isAgeable() || (bakedTogether && COOKED_TRACK.equals(track.getId()))) continue; TagStep step = track.getCurrentStep(); if (step != null) multiplier *= forFood ? step.getFoodMultiplier() : step.getNutritionMultiplier(); } diff --git a/src/main/resources/crafting-stations.yml b/src/main/resources/crafting-stations.yml index 30105a1..3aa8ad5 100644 --- a/src/main/resources/crafting-stations.yml +++ b/src/main/resources/crafting-stations.yml @@ -16,6 +16,8 @@ # and give no pour-return (olives pressed into oil in the cheese press). # inherit: true the output is the input cut differently: it keeps the input's origin, fillers (Banana) and tags # (Sweet, Cooked, its age) that the output line doesn't set. The station takes one such input at a time. +# share-food: true with inherit, each output takes an even share of its input's food (its food over yield) at +# its nutrition, so a pie's slices hold what the pie did instead of their type's own food. cutting_board: furniture: cutting_board single-origin: true diff --git a/src/main/resources/pies.yml b/src/main/resources/pies.yml new file mode 100644 index 0000000..0f07f67 --- /dev/null +++ b/src/main/resources/pies.yml @@ -0,0 +1,128 @@ +# Pies, made in a pie tin and baked in the oven like the bread tray: +# 1. Place a pie tin and right-click it with a piece of dough to line it. +# 2. Right-click with fillings to put them in under the top crust (they don't show). An empty hand takes back what +# went in last: the last filling, then the dough. +# 3. Sneak and right-click with an empty hand to carry the tin, and put it in a lit oven like a baking tray. Only a +# filled pie goes in (dough alone doesn't); an empty tin can. +# 4. Once it's baked, right-click the tin with an empty hand to take the whole pie out (hot, like bread from the +# tray), even from the oven. Cut it into slices on the cutting board with the knife (crafting-stations.yml +# cut_pie). +# The pie's food is what its dough and fillings hold between them, its nutrition their average (weighted by food), +# like a sandwich's; each one's own cooking is left out, since the pie's bake (its cooked tag) cooks all of it, raw +# meat too. Its quality, freshness and variety come from all of them (composition.yml context PIE). Its kind (kinds +# below) gives it its origin, which picks its look through types.yml overrides, and its name: Key Lime Pie; a pie no +# named kind takes is named by its fillings: "Carrot and Onion Pie" (naming.yml fillers max). +# +# The tin needs a slot named by slot below (InteractibleFurniture), at the furniture's centre with scale 1 and +# interactible: false; the dough lies in it and shows the whole pie, drawn in the tin's own coordinates. The tin also +# needs carry: true to go in the oven. + +# Furniture ids pies are made in. +furniture: + - pie_tray + +# The tin's slot the dough lies in. +slot: pie + +# Fillings in a pie: it bakes with at least min-fillings and takes no more than max-fillings. +min-fillings: 1 +max-fillings: 3 + +# The whole pie (types.yml). Its food and nutrition are replaced by what's in it. +output: meal(type=pie;origin=Mixed) + +# Seconds in a lit oven until the pie is cooked, and until it burns (counted from when it went in, or from the last +# filling put in). +bake: + cook-seconds: 5 + burn-seconds: 10 + +# The dough that lines the tin, matched by types.yml type. +# models its look in the tin: a model, or a tag and a model used while the dough has that tag. The pie's bake counts +# as a tag too (raw, cooked, burnt), and so does filled while fillings lie under the top crust. The first +# that exists is used, so later lines are fallbacks; with none, the dough shows as it looks in hand. +crust: + types: + - dough + models: + - rotten ia.tfmc_cooking:pie_crust_rotten + - burnt ia.tfmc_cooking:pie_crust_burnt + - cooked ia.tfmc_cooking:pie_crust_cooked + - filled ia.tfmc_cooking:pie_crust_filled + - ia.tfmc_cooking:pie_crust_raw + +# What goes in a pie, matched by types.yml type; the first that takes a food wins. +# name the filling's part of the pie's name; {origin} is the food's origin (Carrot) +# names a name per origin instead (goat: Goat Cheese) +fillings: + fruit: + types: + - fruit_1 + - fruit_2 + name: "{origin}" + names: + melon slice: Melon + meat: + types: + - meat_red_meat + - meat_poultry + - meat_poultry_leg + - meat_pork + name: "{origin}" + mash: + types: + - mashed_potatoes + name: Mashed Potato + vegetable: + types: + - vegetable_cut + - vegetable_cut_2 + - vegetable_chopped + - vegetable_chopped_2 + - garnish_chopped + name: "{origin}" + cheese: + types: + - cheese + name: Cheese + names: + goat: Goat Cheese + +# The kind of pie its fillings make; the first that matches wins. The pie takes the kind as its origin, so types.yml +# overrides give each kind its own look, and its name in place of the fillings' names. +# layers every one of these fillings (ids above) +# only nothing but these fillings +# origins every one of these origins +# any-origin at least one of these origins +# only-origins nothing but these origins +# min-origins at least this many different origins +# mostly at least half the fillings are this origin +# name what the pie is called by: "{name} Pie" +kinds: + Shepherds: + name: Shepherd's + layers: [meat, mash] + only: [meat, mash, vegetable] + Meat: + name: Meat + layers: [meat] + only: [meat, vegetable] + Apple: + name: Apple + only-origins: [Apple] + Key Lime: + name: Key Lime + only-origins: [Lime] + Mixed Berry: + name: Mixed Berry + only-origins: [Strawberry, Cherry, Grape, Blueberry, Blackberry, Raspberry, Cranberry, Sweet Berries] + min-origins: 2 + Mixed: {} + +# Tags a pie earns from its fillings, written like sandwiches.yml's bonuses. count: fillings counts the fillings. +bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index 48ac3cf..ede4fa6 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -127,7 +127,7 @@ class CookingLifecycleTest { "milling-recipes.yml", "cookware.yml", "tags.yml", "naming.yml", "config.yml", "conversions.yml", "carve-sequences.yml", "quality.yml", "composition.yml", "permission_effects.yml", "farming.yml", "crops.yml", "husbandry.yml", "custom-fishing.yml", "sandwiches.yml", "pizzas.yml", - "condiments.yml")) { + "condiments.yml", "pies.yml")) { Path resource = plugin.getDataFolder().toPath().resolve(name); assertTrue(Files.isRegularFile(resource), name); assertFalse(Files.readString(resource).isBlank(), name); diff --git a/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java index 9a571de..fd62d46 100644 --- a/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java @@ -101,6 +101,7 @@ void recipeAndStationConfigurationUseDeclaredValuesAndDefaults() { assertFalse(defaults.isProcessed()); assertEquals(1, defaults.getYield()); assertFalse(defaults.inherits()); + assertFalse(defaults.sharesFood()); assertFalse(defaults.canAdd(new ItemStack(Material.STONE))); MemoryConfiguration config = env.config(2, true, "tool:knife"); CraftingStation template = new CraftingStation("board", config); @@ -787,6 +788,32 @@ void anInheritingRecipeCutsEachLoafWhereItLiesAndItsSlicesStayWhatThatLoafWas() assertSame(loaf, failed.getSlots().get("input_1"), "A loaf that can't be cut stays whole"); } + @Test + void aSharingRecipeGivesEachSliceItsShareOfTheWholeFood() { + env.parser.when(() -> FoodParser.parse(anyString())).thenAnswer(inv -> { + FoodParser.Result result = new FoodParser.Result(); result.template = env.food(inv.getArgument(0), ""); + return result; + }); + MemoryConfiguration config = env.config(1, false, "tool:knife"); + config.set("recipes.cut.yield", 4); + config.set("recipes.cut.inherit", true); + config.set("recipes.cut.share-food", true); + config.set("recipes.cut.output", "slice"); + assertTrue(new CraftingRecipe("cut", config.getConfigurationSection("recipes.cut")).sharesFood()); + CraftingStation board = new CraftingStation(env.furniture, new CraftingStation("board", config)); + FoodItem pie = env.food("raw", "Apple"); + pie.setBaseFood(36); + pie.setBaseNutrition(7.5); + env.add(board, "input_1", env.stack(pie, Material.PUMPKIN_PIE, 1)); + board.craft(env.player); + FoodItem slice = env.resolve(board.getSlots().get("input_1")); + assertEquals("slice", slice.getId()); + assertEquals(4, board.getSlots().get("input_1").getAmount()); + assertEquals(9, slice.getBaseFood(), 1e-9); + assertEquals(7.5, slice.getBaseNutrition(), 1e-9); + assertEquals("Apple", slice.getOrigin()); + } + @Test void foodTakenFromAStationGoesOntoTheSameFoodInTheInventoryLikeAPickup() { CraftingStation station = env.station(1, false); diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index 867f2fc..2f3362d 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -164,7 +164,7 @@ class CommandManagerCoverageTest { FoodItem roast = new FoodItem("roast", config); staticMock(FoodLoader.class).when(() -> FoodLoader.getByString("roast")).thenReturn(roast); List contexts = List.of("CUTTING_BOARD", "MIXING_BOWL", "SAUCE_SCOOP", "SOUP_SCOOP", "CARVE", - "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "SANDWICH", "PIZZA", "SALAD", "MILLING", "BAKING"); + "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "SANDWICH", "PIZZA", "SALAD", "PIE", "MILLING", "BAKING"); assertEquals(contexts, tabs("qualitytest", "compose", "food(type=roast)", "")); assertEquals(List.of("BAKING"), tabs("qualitytest", "COMPOSE", "food(type=roast)", "bA")); assertEquals(List.of("SAUCE_SCOOP", "SAUSAGE_MAKER", "SANDWICH", "SALAD"), tabs("qualitytest", "compose2", "food(type=roast)|food(type=roast)", "sA")); diff --git a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java index 2c8297f..47b45df 100644 --- a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java @@ -24,12 +24,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; import net.tfminecraft.cooking.baking.BakingTrayCoverageTest.Bakery; import net.tfminecraft.cooking.baking.BakingTrayState; import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cooking.PotReference; import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.pie.PieConfig; +import net.tfminecraft.cooking.pie.PieTin; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.pizza.PizzaConfig; import net.tfminecraft.cooking.pizza.PizzaPeel; @@ -246,7 +250,7 @@ void cavitiesAcceptOnlyBakingTraysInFurnitureSlots() { handler.onFurnitureAdd(allowed); assertFalse(allowed.isCancelled()); var empty = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, null); handler.onFurnitureAdd(empty); assertFalse(empty.isCancelled()); var wrong = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, oven.furniture); handler.onFurnitureAdd(wrong); assertTrue(wrong.isCancelled()); - assertTrue(env.messages.contains("§cOnly baking trays and pizza peels can go in the oven.")); + assertTrue(env.messages.contains("§cOnly baking trays, pie tins and pizza peels can go in the oven.")); var other = new FurnitureSlotFurnitureAddEvent(env.player, oven.furniture, slot, bakery.tray.furniture); handler.onFurnitureAdd(other); assertFalse(other.isCancelled()); when(slot.getSlotType()).thenReturn(SlotType.ITEM); var itemSlot = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, oven.furniture); handler.onFurnitureAdd(itemSlot); assertFalse(itemSlot.isCancelled()); @@ -274,6 +278,38 @@ void cavitiesTakePizzaPeelsAndBakeTheirPizzas(@TempDir Path directory) throws Ex } } + @Test + void cavitiesTakeEmptyTinsAndFilledPiesButNotDoughAloneAndBakeThePies(@TempDir Path directory) throws Exception { + Path config = directory.resolve("pies.yml"); + Files.writeString(config, "furniture: [pie_tray]\nbake:\n cook-seconds: 1\n burn-seconds: 2\n" + + "crust:\n types: [dough]\nfillings:\n fruit:\n types: [fruit_2]\n"); + new PieConfig().load(config.toFile()); + try (MockedStatic codec = mockStatic(PotReference.class)) { + ItemStack apple = env.stack(bakery.food("fruit_2", null), Material.APPLE, 1); + codec.when(() -> PotReference.decodeStack("apple")).thenReturn(apple); + Station tin = bakery.station("pie_tray"); tin.define("pie"); + var slot = cavity.define("tray"); when(slot.getSlotType()).thenReturn(SlotType.FURNITURE); + var empty = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, tin.furniture); + new OvenCavityHandler().onFurnitureAdd(empty); assertFalse(empty.isCancelled()); + + tin.place("pie", env.stack(bakery.food("dough", null), Material.WHEAT, 1)); + var dough = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, tin.furniture); + new OvenCavityHandler().onFurnitureAdd(dough); assertTrue(dough.isCancelled()); + assertTrue(env.messages.contains("§cFill the pie before baking it.")); + + tin.variables.put("pie.fillings", "apple"); + var filled = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, tin.furniture); + new OvenCavityHandler().onFurnitureAdd(filled); assertFalse(filled.isCancelled()); + + bakery.nest(cavity, "tin", tin.furniture); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(20); + assertEquals(PieTin.COOKED, PieTin.baked(tin.furniture)); + verify(env.manager).markDirty(tin.furniture); + } finally { + new PieConfig().load(directory.resolve("missing.yml").toFile()); + } + } + @Test void cavityClocksPauseWithoutHeatThenCookAndBurnEachLoafAtItsOwnThreshold() { bakery.tray.place("left_a", bakery.loaf(0, 1)); bakery.tray.place("left_b", bakery.loaf(1, 1)); diff --git a/src/test/java/net/tfminecraft/cooking/pie/PieCoverageTest.java b/src/test/java/net/tfminecraft/cooking/pie/PieCoverageTest.java new file mode 100644 index 0000000..ca8f571 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/pie/PieCoverageTest.java @@ -0,0 +1,564 @@ +package net.tfminecraft.cooking.pie; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.cooking.PotReference; +import net.tfminecraft.cooking.crafting.CraftingStationCoverageTest.Environment; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.salad.SaladKind; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class PieCoverageTest { + static final String CONFIG = """ + furniture: [BOARD] + slot: pie + max-fillings: 3 + output: meal(type=pie) + bake: + cook-seconds: 2 + burn-seconds: 4 + crust: + types: [dough] + models: + - burnt ia.crust_burnt + - cooked ia.crust_cooked + - filled ia.crust_filled + - ia.crust_raw + fillings: + fruit: + types: [fruit_2] + name: "{origin}" + meat: + types: [meat_red_meat] + name: "{origin}" + mash: + types: [mashed_potatoes] + name: Mashed Potato + spice: + types: [spice_1] + names: + salt: "" + plain: 3 + kinds: + Shepherds: + name: Shepherd's + layers: [meat, mash] + only: [meat, mash] + Meat: + name: Meat + only: [meat] + Apple: + name: Apple + only-origins: [Apple] + Mixed Berry: + name: Mixed Berry + only-origins: [Strawberry, Cherry] + min-origins: 2 + Mixed: {} + odd: 3 + bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty + """; + + Environment env; + MockedStatic adder; + final List modelRefs = new ArrayList<>(); + final List given = new ArrayList<>(); + final List messages = new ArrayList<>(); + final Map variables = new HashMap<>(); + final Map encoded = new HashMap<>(); + ItemStack rest; + FoodItem built; + final PieHandler handler = new PieHandler(); + @TempDir Path directory; + + @BeforeEach void open() throws Exception { + env = new Environment(); + env.define("pie"); + doAnswer(inv -> { + String id = inv.getArgument(0); + return env.active.containsKey(id) ? env.active.get(id) : slot(id, null); + }).when(env.furniture).getOrCreatePlacedSlot(anyString()); + when(env.furniture.getVariables()).thenReturn(variables); + when(env.api.getCreator().getItemFromPath(anyString())).thenAnswer(inv -> { + String ref = inv.getArgument(0); + modelRefs.add(ref); + // There's no burnt look, so a burnt pie keeps the look it had. + return new ItemStack(ref.contains("burnt") ? Material.DIRT : Material.STICK); + }); + env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(inv -> { + built = inv.getArgument(0); + return env.stack(built, Material.PUMPKIN_PIE, 1); + }); + adder = env.scoped(mockStatic(InventoryAdder.class)); + adder.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> { + given.add(inv.getArgument(1)); + return rest; + }); + MockedStatic codec = env.scoped(mockStatic(PotReference.class)); + codec.when(() -> PotReference.encodeStack(any())).thenAnswer(inv -> { + String key = "e" + encoded.size(); + encoded.put(key, inv.getArgument(0)); + return key; + }); + codec.when(() -> PotReference.decodeStack(anyString())).thenAnswer(inv -> encoded.get((String) inv.getArgument(0))); + doAnswer(inv -> { messages.add(inv.getArgument(0)); return null; }).when(env.player).sendMessage(anyString()); + load(CONFIG); + } + + @AfterEach void close() { + new PieConfig().load(directory.resolve("missing.yml").toFile()); + env.close(); + } + + void load(String yaml) throws Exception { + Path file = directory.resolve("pies.yml"); + Files.writeString(file, yaml); + new PieConfig().load(file.toFile()); + } + + PlacedSlot slot(String id, ItemStack item) { + PlacedSlot slot = env.place(id, item); + when(slot.getId()).thenReturn(id); + return slot; + } + + FoodItem food(String type, String origin, double food, double nutrition) { + FoodItem item = env.food(type, origin); + item.setBaseFood(food); + item.setBaseNutrition(nutrition); + return item; + } + + ItemStack dough() { return env.stack(food("dough", "Wheat", 4, 4), Material.WHEAT, 4); } + ItemStack fruit(String origin) { return env.stack(food("fruit_2", origin, 10, 8), Material.APPLE, 4); } + ItemStack meat(String origin) { return env.stack(food("meat_red_meat", origin, 20, 8), Material.BEEF, 4); } + ItemStack mash() { return env.stack(food("mashed_potatoes", "Potato", 12, 6), Material.BAKED_POTATO, 4); } + ItemStack spice(String origin) { return env.stack(food("spice_1", origin, 1, 1), Material.GREEN_DYE, 4); } + + FurnitureInteractEvent click(ItemStack held) { + env.player.getInventory().setItemInMainHand(held); + FurnitureInteractEvent event = new FurnitureInteractEvent(env.player, env.furniture); + handler.onInteract(event); + return event; + } + + ItemStack crust() { + PlacedSlot slot = env.active.get("pie"); + return slot == null ? null : slot.getCurrentItem(); + } + + List fillingIds() { + List ids = new ArrayList<>(); + for (ItemStack stack : PieTin.fillings(env.furniture)) ids.add(env.resolve(stack).getOrigin()); + return ids; + } + + void filled(ItemStack... fillings) { + slot("pie", dough()); + PieTin.setFillings(env.furniture, new ArrayList<>(List.of(fillings))); + } + + Furniture other() { + Furniture other = mock(Furniture.class); + FurnitureType otherType = mock(FurnitureType.class); + when(other.getType()).thenReturn(otherType); + when(otherType.getId()).thenReturn("plate"); + when(other.getLoc()).thenReturn(new Location(env.world, 1, 64, 1)); + return other; + } + + FoodItem pie(ItemStack... fillings) { + assertNotNull(PieItems.build(env.player, dough(), List.of(fillings), PieTin.COOKED)); + return built; + } + + @Test + void configReadsTinsLayersKindsAndBakeAndFallsBackToDefaults() throws Exception { + assertTrue(PieConfig.isTin(env.furniture)); + assertFalse(PieConfig.isTin(null)); + assertFalse(PieConfig.isTin(mock(Furniture.class))); + assertEquals("pie", PieConfig.slot()); + assertEquals(1, PieConfig.minFillings()); + assertEquals(3, PieConfig.maxFillings()); + assertEquals("meal(type=pie)", PieConfig.output()); + assertEquals(2, PieConfig.cookSeconds()); + assertEquals(4, PieConfig.burnSeconds()); + assertEquals(1, PieConfig.bonuses().size()); + FoodItem dough = env.food("dough", "Wheat"); + assertTrue(PieConfig.isCrust(PieConfig.crustFor(dough))); + assertSame(PieConfig.crustFor(dough), PieConfig.layerFor(dough)); + assertNull(PieConfig.fillingFor(dough)); + assertEquals("meat", PieConfig.layerFor(env.food("meat_red_meat", "Beef")).getId()); + assertFalse(PieConfig.isCrust(PieConfig.layerFor(env.food("meat_red_meat", "Beef")))); + assertFalse(PieConfig.isCrust(null)); + assertNull(PieConfig.layerFor(env.food("cheese", "Cow"))); + assertNull(PieConfig.layerFor(null)); + + new PieConfig().load(directory.resolve("missing.yml").toFile()); + assertFalse(PieConfig.isTin(env.furniture)); + assertEquals("pie", PieConfig.slot()); + assertEquals(1, PieConfig.minFillings()); + assertEquals(3, PieConfig.maxFillings()); + assertEquals("meal(type=pie;origin=Mixed)", PieConfig.output()); + assertEquals(5, PieConfig.cookSeconds()); + assertEquals(10, PieConfig.burnSeconds()); + assertNull(PieConfig.crustFor(dough)); + assertNull(PieConfig.layerFor(dough)); + assertNull(PieConfig.kindFor(List.of(), List.of())); + assertTrue(PieConfig.bonuses().isEmpty()); + + // A pie can't burn before it's cooked, nor need more fillings than it takes. + load("slot: tin\nmin-fillings: 5\nmax-fillings: 0\nbake:\n cook-seconds: 30\n burn-seconds: 10\n"); + assertEquals("tin", PieConfig.slot()); + assertEquals(1, PieConfig.maxFillings()); + assertEquals(1, PieConfig.minFillings()); + assertEquals(31, PieConfig.burnSeconds()); + } + + @Test + void kindsAreShepherdsMeatAppleOrMixedBerryAndTheRestAreMixed() { + SandwichLayer fruit = PieConfig.fillingFor(env.food("fruit_2", "Apple")); + SandwichLayer meat = PieConfig.fillingFor(env.food("meat_red_meat", "Beef")); + SandwichLayer mash = PieConfig.fillingFor(env.food("mashed_potatoes", "Potato")); + FoodItem apple = env.food("fruit_2", "Apple"); + FoodItem cherry = env.food("fruit_2", "Cherry"); + FoodItem strawberry = env.food("fruit_2", "Strawberry"); + FoodItem beef = env.food("meat_red_meat", "Beef"); + FoodItem potato = env.food("mashed_potatoes", "Potato"); + assertEquals("Shepherds", PieConfig.kindFor(List.of(beef, potato), List.of(meat, mash)).getOrigin()); + assertEquals("Meat", PieConfig.kindFor(List.of(beef, beef), List.of(meat, meat)).getOrigin()); + assertEquals("Mixed", PieConfig.kindFor(List.of(potato), List.of(mash)).getOrigin()); + SaladKind applePie = PieConfig.kindFor(List.of(apple, apple), List.of(fruit, fruit)); + assertEquals("Apple", applePie.getOrigin()); + assertEquals("Apple", applePie.getName()); + assertEquals("Mixed", PieConfig.kindFor(List.of(apple, cherry), List.of(fruit, fruit)).getOrigin(), + "An apple and a cherry are neither an apple pie nor a berry pie"); + assertEquals("Mixed Berry", PieConfig.kindFor(List.of(cherry, strawberry), List.of(fruit, fruit)).getOrigin()); + SaladKind single = PieConfig.kindFor(List.of(cherry, cherry), List.of(fruit, fruit)); + assertEquals("Mixed", single.getOrigin(), "Mixed berry takes two kinds of berry"); + assertNull(single.getName()); + } + + @Test + void piesSumTheirDoughAndFillingsWithThePiesOwnBakeAndAreNamedByTheirKind() throws Exception { + FoodItem raw = food("meat_red_meat", "Beef", 20, 8); + TagTrack uncooked = new TagTrack(TrackLoader.getByString("cooked")); + uncooked.setValue(0); + raw.addOrModifyTrack(uncooked); + FoodItem cut = food("mashed_potatoes", "Potato", 12, 6); + cut.addOrModifyTrack(new TagTrack("plain", false, List.of())); + TagTrack mashed = new TagTrack(TrackLoader.getByString("mashed")); + cut.addOrModifyTrack(mashed); + ItemStack stack = PieItems.build(env.player, dough(), List.of(env.stack(raw, Material.BEEF, 1), + env.stack(cut, Material.BAKED_POTATO, 1), spice("Salt")), PieTin.COOKED); + assertNotNull(stack); + assertEquals("meal(type=pie)", built.getId()); + // The raw beef counts whole: the pie's bake cooks it. Dough 4, beef 20, mash 12 (and what its mashing does), + // salt 1. + double mash = 12 * mashed.getCurrentStep().getFoodMultiplier(); + assertEquals(4 + 20 + mash + 1, built.getBaseFood(), 1e-9); + double nutrition = 4 * 4 + 20 * 8 + mash * 6 * mashed.getCurrentStep().getNutritionMultiplier() + 1; + assertEquals(nutrition / (4 + 20 + mash + 1), built.getBaseNutrition(), 1e-9); + assertEquals("Mixed", built.getOrigin(), "Salt isn't meat or mash, so it's no shepherd's pie"); + assertEquals(List.of("Beef", "Mashed Potato"), built.getIngredients(), "Salt doesn't name it"); + assertEquals(PieTin.COOKED, built.getTagTrack("cooked").getValue()); + assertNotNull(built.getTagTrack("warmth"), "It comes out of the oven hot"); + assertNotNull(built.getTagTrack("dish_size"), "Three fillings make it hearty"); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), argThat(l -> l.size() == 4), + eq(CompositionContext.PIE))); + + assertEquals("Shepherds", pie(meat("Beef"), mash()).getOrigin()); + assertEquals(List.of("Shepherd's"), built.getIngredients()); + assertNull(built.getTagTrack("dish_size")); + assertEquals("Apple", pie(fruit("Apple")).getOrigin()); + assertEquals(List.of("Apple"), built.getIngredients()); + + // Something that isn't food, or isn't a filling, adds nothing to its name. + PieItems.build(env.player, new ItemStack(Material.STONE), List.of(new ItemStack(Material.STONE), + env.stack(env.food("cheese", "Cow"), Material.YELLOW_WOOL, 1), fruit("Cherry")), PieTin.BURNT); + assertEquals(List.of("Cherry"), built.getIngredients()); + assertEquals(PieTin.BURNT, built.getTagTrack("cooked").getValue()); + + load(CONFIG.replaceAll("(?s)kinds:.*?bonuses:", "bonuses:")); + List tracks = TrackLoader.oList; + TrackLoader.oList = new ArrayList<>(); + try { + pie(fruit("Cherry")); + } finally { + TrackLoader.oList = tracks; + } + assertEquals("Beef", built.getOrigin(), "No kind takes it, so it keeps the output's origin"); + assertEquals(List.of("Cherry"), built.getIngredients()); + assertNull(built.getTagTrack("cooked")); + + assertNull(PieItems.build(env.player, new ItemStack(Material.STONE), List.of(), PieTin.COOKED)); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(new FoodParser.Result()); + assertNull(PieItems.build(env.player, dough(), List.of(), PieTin.COOKED)); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + assertNull(PieItems.build(env.player, dough(), List.of(), PieTin.COOKED)); + } + + @Test + void anEmptyTinOnlyTakesDoughAndLeavesEverythingElseToTheFurniture() { + env.player.getInventory().setItemInMainHand(dough()); + FurnitureInteractEvent elsewhere = new FurnitureInteractEvent(env.player, other()); + handler.onInteract(elsewhere); + assertFalse(elsewhere.isCancelled()); + + // An empty hand picks the empty tin up; something that isn't food is left alone. + assertFalse(click(null).isCancelled()); + assertFalse(click(new ItemStack(Material.STONE)).isCancelled()); + + assertTrue(click(fruit("Apple")).isCancelled()); + assertEquals(List.of("§7Line the tin with a piece of dough first."), messages); + assertNull(crust()); + + when(env.furniture.isAttached()).thenReturn(true); + assertTrue(click(dough()).isCancelled()); + assertEquals("§7Take the tin out of the oven first.", messages.get(1)); + assertNull(crust()); + } + + @Test + void sneakingWithAnEmptyHandCarriesTheTin() { + filled(fruit("Apple")); + when(env.player.isSneaking()).thenReturn(true); + assertFalse(click(null).isCancelled()); + assertEquals(List.of("Apple"), fillingIds()); + } + + @Test + void doughLinesTheTinFillingsGoUnderItsCrustAndAnEmptyHandTakesTheLastBack() { + variables.put(PieTin.BAKED, "not a number"); + assertTrue(click(dough()).isCancelled()); + assertEquals(3, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals("dough", env.resolve(crust()).getId()); + assertEquals(List.of("ia.crust_raw"), modelRefs); + assertEquals(0, variables.get(PieTin.BAKED)); + assertFalse(PieTin.ready(env.furniture)); + + click(dough()); + click(env.stack(env.food("cheese", "Cow"), Material.YELLOW_WOOL, 1)); + assertEquals(List.of("§7There's already dough in this tin.", "§7That doesn't go in a pie."), messages); + + variables.put(PieTin.ELAPSED, 1); + click(fruit("Apple")); + assertEquals(List.of("Apple"), fillingIds()); + assertEquals("ia.crust_filled", modelRefs.get(modelRefs.size() - 1)); + assertEquals(0, PieTin.elapsed(env.furniture), "A new filling bakes through from the start"); + assertTrue(PieTin.ready(env.furniture)); + click(fruit("Cherry")); + click(meat("Beef")); + click(mash()); + assertEquals("§7The pie is full; bake it in the oven.", messages.get(2)); + assertEquals(List.of("Apple", "Cherry", "Beef"), fillingIds()); + + // An empty hand takes back the last filling, then the dough as it looks in hand. + assertTrue(click(null).isCancelled()); + assertEquals("Beef", env.resolve(given.get(0)).getOrigin()); + assertEquals(List.of("Apple", "Cherry"), fillingIds()); + click(null); + click(null); + assertFalse(variables.containsKey(PieTin.FILLINGS)); + assertEquals("ia.crust_raw", modelRefs.get(modelRefs.size() - 1)); + click(null); + assertEquals("dough", env.resolve(given.get(3)).getId()); + env.updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), isNull())); + assertNull(crust()); + assertTrue(variables.isEmpty()); + } + + @Test + void aBakedPieComesOutWholeEvenFromTheOvenAndTakesNothingMore() { + filled(fruit("Apple"), fruit("Apple")); + PieTin.setBake(env.furniture, 3, PieTin.COOKED); + + click(fruit("Apple")); + assertEquals(List.of("§7This pie is baked; take it out with an empty hand."), messages); + when(env.furniture.isAttached()).thenReturn(true); + click(fruit("Apple")); + assertEquals("§7Take the tin out of the oven first.", messages.get(1)); + + assertTrue(click(null).isCancelled()); + assertEquals(1, given.size()); + assertEquals(Material.PUMPKIN_PIE, given.get(0).getType()); + assertEquals("Apple", built.getOrigin()); + assertNull(crust()); + assertTrue(variables.isEmpty()); + env.server.getPluginManager().assertEventFired(DishCookedEvent.class); + } + + @Test + void aRawPieInTheOvenIsLeftToTheFurniture() { + filled(fruit("Apple")); + when(env.furniture.isAttached()).thenReturn(true); + assertFalse(click(null).isCancelled()); + assertEquals(List.of("Apple"), fillingIds()); + } + + @Test + void aPieThatCannotBeMadeStaysInTheTin() { + filled(fruit("Apple")); + PieTin.setBake(env.furniture, 5, PieTin.BURNT); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + click(null); + assertEquals(List.of("§cThis pie can't be made right now."), messages); + assertNotNull(crust()); + assertTrue(given.isEmpty()); + } + + @Test + void whatDoesNotFitInThePlayersInventoryDropsAtTheirFeet() { + rest = new ItemStack(Material.WHEAT); + click(dough()); + click(null); + assertEquals(1, env.dropped.size()); + } + + @Test + void aBrokenTinDropsABakedPieWholeAndARawOneAsItsDoughAndFillings() { + handler.onBreak(new FurnitureBreakEvent(other(), env.player)); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertTrue(env.dropped.isEmpty()); + + filled(fruit("Apple"), meat("Beef")); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertEquals(List.of("dough", "fruit_2", "meat_red_meat"), env.dropped.stream().map(i -> env.resolve(i).getId()).toList()); + assertNull(crust()); + assertTrue(variables.isEmpty()); + + env.dropped.clear(); + filled(fruit("Apple")); + PieTin.setBake(env.furniture, 3, PieTin.COOKED); + handler.onBreak(new FurnitureBreakEvent(env.furniture, null)); + assertEquals(List.of(Material.PUMPKIN_PIE), env.dropped.stream().map(ItemStack::getType).toList()); + + // A baked pie that can't be made drops what it was made from; dough the plugin can't read drops as it is. + env.dropped.clear(); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + slot("pie", new ItemStack(Material.STONE)); + PieTin.setBake(env.furniture, 3, PieTin.COOKED); + handler.onBreak(new FurnitureBreakEvent(env.furniture, null)); + assertEquals(List.of(Material.STONE), env.dropped.stream().map(ItemStack::getType).toList()); + + env.dropped.clear(); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + ItemStack dough = dough(); + slot("pie", dough); + handler.onBreak(new FurnitureBreakEvent(env.furniture, null)); + assertEquals("dough", env.resolve(env.dropped.get(0)).getId()); + } + + @Test + void onlyAFilledPieGoesInTheOvenButAnEmptyTinDoes() { + assertNull(PieTin.ovenRefusal(env.furniture)); + slot("pie", dough()); + assertEquals("§cFill the pie before baking it.", PieTin.ovenRefusal(env.furniture)); + PieTin.setFillings(env.furniture, new ArrayList<>(List.of(fruit("Apple")))); + assertNull(PieTin.ovenRefusal(env.furniture)); + + // An empty slot, or one holding air, is no dough. + env.active.clear(); + slot("pie", null); + assertNull(PieTin.crust(env.furniture)); + slot("pie", new ItemStack(Material.AIR)); + assertNull(PieTin.crust(env.furniture)); + variables.put(PieTin.FILLINGS, 7); + assertTrue(PieTin.fillings(env.furniture).isEmpty()); + variables.put(PieTin.FILLINGS, "gone,e0"); + assertEquals(1, PieTin.fillings(env.furniture).size(), "A filling that can't be read is lost"); + } + + @Test + void ovensBakeOnlyAFilledPieWhileHotThenShowItCookedAndBurnt() { + Furniture oven = other(); + PieBaking.tick(oven, env.furniture); + assertTrue(variables.isEmpty()); + + slot("pie", dough()); + PieBaking.tick(oven, env.furniture); + assertTrue(variables.isEmpty(), "Dough alone doesn't bake"); + + PieTin.setFillings(env.furniture, new ArrayList<>(List.of(fruit("Apple")))); + env.heated = false; + PieBaking.tick(oven, env.furniture); + assertFalse(variables.containsKey(PieTin.ELAPSED)); + + env.heated = true; + PieBaking.tick(oven, env.furniture); + assertEquals(1, PieTin.elapsed(env.furniture)); + assertEquals(PieTin.RAW, PieTin.baked(env.furniture)); + assertTrue(modelRefs.isEmpty()); + PieBaking.tick(oven, env.furniture); + assertEquals(PieTin.COOKED, PieTin.baked(env.furniture)); + assertEquals(List.of("ia.crust_cooked"), modelRefs); + PieBaking.tick(oven, env.furniture); + PieBaking.tick(oven, env.furniture); + assertEquals(PieTin.BURNT, PieTin.baked(env.furniture)); + // No burnt look exists, so it falls back to the next one that fits. + assertEquals(List.of("ia.crust_cooked", "ia.crust_burnt", "ia.crust_filled"), modelRefs); + PieBaking.tick(oven, env.furniture); + assertEquals(4, PieTin.elapsed(env.furniture)); + verify(InteractibleFurniture.getInstance().getFurnitureManager(), times(4)).markDirty(env.furniture); + + variables.put(PieTin.BAKED, 9); + assertEquals(PieTin.BURNT, PieTin.baked(env.furniture)); + variables.put(PieTin.BAKED, -3); + assertEquals(PieTin.RAW, PieTin.baked(env.furniture)); + } + + @Test + void aTinWithoutAnyLookForItsDoughShowsTheDoughAsItIs() throws Exception { + // Something in the slot that isn't the crust keeps its look. + ItemStack stone = new ItemStack(Material.STONE); + slot("pie", stone); + PieTin.remodel(env.furniture); + assertSame(stone, crust()); + PieTin.remodel(other()); + + load(CONFIG.replaceAll("(?s) models:.*?fillings:", "fillings:")); + ItemStack dough = dough(); + slot("pie", dough); + PieTin.remodel(env.furniture); + assertSame(dough, crust()); + assertTrue(modelRefs.isEmpty()); + } +}