From 120d5e494a2adf95a828128fa7ab578aefdcb12f Mon Sep 17 00:00:00 2001 From: Justinas Launikonis <95593194+JustinasLa@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:30:24 +0200 Subject: [PATCH] feat: build pizzas on a peel, bake them in the oven and serve them in slices Dough on a placed pizza peel starts a pizza; a paste can be spread straight on it and cheese, vegetables, fruit, sausage and spices go on top. Carried into a lit oven like a baking tray, it cooks and then burns, and every layer shows it. A baked pizza is taken off a slice at a time; every slice of a pizza is the same, and the pizza on the peel shows the slices gone. Layer lifts follow the peel's size, so the layers stay on the peel in an oven's cavity, which draws it smaller. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../java/net/tfminecraft/cooking/Cooking.java | 8 + .../cooking/cooking/PotReference.java | 4 +- .../cooking/oven/OvenCavityHandler.java | 5 +- .../cooking/oven/OvenCavityManager.java | 6 + .../cooking/pizza/PizzaBaking.java | 30 + .../cooking/pizza/PizzaConfig.java | 106 ++++ .../cooking/pizza/PizzaHandler.java | 243 ++++++++ .../tfminecraft/cooking/pizza/PizzaItems.java | 91 +++ .../tfminecraft/cooking/pizza/PizzaPeel.java | 174 ++++++ .../cooking/quality/CompositionContext.java | 1 + .../cooking/sandwich/SandwichLayer.java | 34 +- src/main/resources/pizzas.yml | 123 ++++ .../cooking/CookingLifecycleTest.java | 8 +- .../crafting/CraftingStationCoverageTest.java | 4 +- .../manager/CommandManagerCoverageTest.java | 2 +- .../cooking/oven/OvenCoverageTest.java | 29 +- .../cooking/pizza/PizzaCoverageTest.java | 557 ++++++++++++++++++ 18 files changed, 1412 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/pizza/PizzaBaking.java create mode 100644 src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java create mode 100644 src/main/java/net/tfminecraft/cooking/pizza/PizzaHandler.java create mode 100644 src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java create mode 100644 src/main/java/net/tfminecraft/cooking/pizza/PizzaPeel.java create mode 100644 src/main/resources/pizzas.yml create mode 100644 src/test/java/net/tfminecraft/cooking/pizza/PizzaCoverageTest.java diff --git a/README.md b/README.md index 7b79868..65424e5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ 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`). - **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 4fc8fd0..dd878ef 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -62,6 +62,8 @@ import net.tfminecraft.cooking.baking.BakingTrayAging; import net.tfminecraft.cooking.baking.BakingTrayHandler; import net.tfminecraft.cooking.baking.BakingTrayLoader; +import net.tfminecraft.cooking.pizza.PizzaConfig; +import net.tfminecraft.cooking.pizza.PizzaHandler; import net.tfminecraft.cooking.sandwich.SandwichConfig; import net.tfminecraft.cooking.sandwich.SandwichHandler; import net.tfminecraft.cooking.milling.MillingRecipeLoader; @@ -135,6 +137,8 @@ public class Cooking extends JavaPlugin { private final BakingTrayHandler bakingTrayHandler = new BakingTrayHandler(); private final SandwichConfig sandwichConfig = new SandwichConfig(); private final SandwichHandler sandwichHandler = new SandwichHandler(); + private final PizzaConfig pizzaConfig = new PizzaConfig(); + private final PizzaHandler pizzaHandler = new PizzaHandler(); private final MillingStoneHandler millingStoneHandler = new MillingStoneHandler(); private final TroughHandler troughHandler = new TroughHandler(); private final TroughAging troughAging = new TroughAging(); @@ -191,6 +195,7 @@ public void onEnable() { liquidContainerHandler.resumeLoadedContainers(); meatHookHandler.resumeLoadedHooks(); sandwichHandler.resumeLoaded(); + pizzaHandler.start(); warnIfCharacterIdentityMissing(getLogger()); SaturationGuard.start(); NutritionDrainTask.start(); @@ -258,6 +263,7 @@ public void loadConfigs() { foodLoader.load(new File(getDataFolder(), "types.yml")); bakingTrayLoader.load(new File(getDataFolder(), "baking-trays.yml")); sandwichConfig.load(new File(getDataFolder(), "sandwiches.yml")); + pizzaConfig.load(new File(getDataFolder(), "pizzas.yml")); millingRecipeLoader.load(new File(getDataFolder(), "milling-recipes.yml")); stationLoader.load(new File(getDataFolder(), "crafting-stations.yml")); trackLoader.load(new File(getDataFolder(), "tags.yml")); @@ -322,6 +328,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(sausageMakerHandler, this); getServer().getPluginManager().registerEvents(bakingTrayHandler, this); getServer().getPluginManager().registerEvents(sandwichHandler, this); + getServer().getPluginManager().registerEvents(pizzaHandler, this); getServer().getPluginManager().registerEvents(millingStoneHandler, this); getServer().getPluginManager().registerEvents(troughHandler, this); getServer().getPluginManager().registerEvents(ovenLifecycleHandler, this); @@ -416,6 +423,7 @@ public void createConfigs() { "crafting-stations.yml", "baking-trays.yml", "sandwiches.yml", + "pizzas.yml", "milling-recipes.yml", "cookware.yml", "tags.yml", diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index 61346a9..c5aecbb 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -549,7 +549,7 @@ private void restoreExtras() { // Preserve the existing serialized item format so previously saved graves remain readable. @SuppressWarnings("deprecation") - static String encodeStack(ItemStack stack) { + public static String encodeStack(ItemStack stack) { if (stack == null) return null; try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); @@ -564,7 +564,7 @@ static String encodeStack(ItemStack stack) { // Preserve the existing serialized item format so previously saved graves remain readable. @SuppressWarnings("deprecation") - static ItemStack decodeStack(String payload) { + public static ItemStack decodeStack(String payload) { if (payload == null || payload.isBlank()) return null; try { byte[] bytes = Base64.getDecoder().decode(payload); diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java index f78dadb..0ed0456 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityHandler.java @@ -6,6 +6,7 @@ import net.tfminecraft.cooking.baking.BakingTrayRegistry; import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.pizza.PizzaConfig; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotFurnitureAddEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.SlotType; @@ -23,11 +24,11 @@ public void onFurnitureAdd(FurnitureSlotFurnitureAddEvent event) { } Furniture nested = event.getNested(); - if (nested == null || BakingTrayRegistry.isTray(nested)) { + if (nested == null || BakingTrayRegistry.isTray(nested) || PizzaConfig.isPeel(nested)) { return; } event.setCancelled(true); - event.getPlayer().sendMessage("§cOnly baking trays can go in the oven."); + event.getPlayer().sendMessage("§cOnly baking trays and pizza peels can go in the oven."); } } diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java index 631d871..c401ca3 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.pizza.PizzaBaking; +import net.tfminecraft.cooking.pizza.PizzaConfig; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedFurnitureSlot; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -46,6 +48,10 @@ private void tick() { for (PlacedFurnitureSlot slot : consumer.getActiveFurnitureSlots().values()) { Furniture tray = slot.getNested(); + if (PizzaConfig.isPeel(tray)) { + PizzaBaking.tick(consumer, tray); + continue; + } BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(tray); if (recipe == null) { continue; diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaBaking.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaBaking.java new file mode 100644 index 0000000..4078a37 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaBaking.java @@ -0,0 +1,30 @@ +package net.tfminecraft.cooking.pizza; + +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** + * Bakes the pizza on a peel in an oven, one second at a time while the oven is hot: it cooks after pizzas.yml's + * cook-seconds and burns at burn-seconds, and every layer then shows its cooked or burnt look. + */ +public final class PizzaBaking { + + private PizzaBaking() {} + + public static void tick(Furniture oven, Furniture peel) { + int baked = PizzaPeel.baked(peel); + if (baked >= PizzaPeel.BURNT || PizzaPeel.layers(peel).isEmpty() || !HeatSources.consumerHasHeat(oven)) return; + int elapsed = PizzaPeel.elapsed(peel) + 1; + int next = baked; + if (elapsed >= PizzaConfig.burnSeconds()) next = PizzaPeel.BURNT; + else if (elapsed >= PizzaConfig.cookSeconds()) next = PizzaPeel.COOKED; + PizzaPeel.setBake(peel, elapsed, next); + if (next != baked) { + // Slices still to be cut take the new bake. + PizzaPeel.forgetSlice(peel); + PizzaPeel.remodel(peel); + } + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(peel); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java new file mode 100644 index 0000000..31aa0fb --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaConfig.java @@ -0,0 +1,106 @@ +package net.tfminecraft.cooking.pizza; + +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.sandwich.SandwichLayer; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** + * pizzas.yml: the peels pizzas are built on, the slices they're cut into, how long they bake and the layers they take. + */ +public final class PizzaConfig { + private static Set furniture = Set.of(); + private static int maxToppings = 6; + private static String output = "meal(type=pizza_slice;origin=Mixed)"; + private static int slices = 4; + private static int cookSeconds = 20; + private static int burnSeconds = 60; + private static SandwichLayer base; + private static SandwichLayer sauce; + private static List toppings = List.of(); + + public void load(File file) { + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + Set peels = new HashSet<>(); + for (String id : config.getStringList("furniture")) peels.add(id.toLowerCase(Locale.ROOT)); + List loaded = new ArrayList<>(); + ConfigurationSection section = config.getConfigurationSection("toppings"); + if (section != null) { + for (String id : section.getKeys(false)) { + ConfigurationSection topping = section.getConfigurationSection(id); + if (topping != null) loaded.add(new SandwichLayer(id, topping)); + } + } + furniture = peels; + maxToppings = Math.max(1, config.getInt("max-toppings", 6)); + output = config.getString("output", "meal(type=pizza_slice;origin=Mixed)"); + slices = Math.max(1, config.getInt("slices", 4)); + cookSeconds = Math.max(1, config.getInt("bake.cook-seconds", 20)); + burnSeconds = Math.max(cookSeconds + 1, config.getInt("bake.burn-seconds", 60)); + base = layer(config, "base"); + sauce = layer(config, "sauce"); + toppings = loaded; + } + + private static SandwichLayer layer(YamlConfiguration config, String id) { + ConfigurationSection section = config.getConfigurationSection(id); + return section == null ? null : new SandwichLayer(id, section); + } + + public static boolean isPeel(Furniture f) { + return f != null && f.getType() != null && furniture.contains(f.getType().getId().toLowerCase(Locale.ROOT)); + } + + /** The layer this food makes (the base, the sauce or a topping), or null when it doesn't go on a pizza. */ + public static SandwichLayer layerFor(FoodItem food) { + if (base != null && base.matches(food)) return base; + if (sauce != null && sauce.matches(food)) return sauce; + for (SandwichLayer topping : toppings) { + if (topping.matches(food)) return topping; + } + return null; + } + + /** The dough every pizza starts with. */ + public static boolean isBase(SandwichLayer layer) { + return layer != null && layer == base; + } + + /** The one spread that goes straight on the dough. */ + public static boolean isSauce(SandwichLayer layer) { + return layer != null && layer == sauce; + } + + public static int maxToppings() { + return maxToppings; + } + + /** The food line of one slice of the finished pizza (types.yml type, origin, tags). */ + public static String output() { + return output; + } + + /** Slices a baked pizza is cut into; 1 takes it off the peel whole. */ + public static int slices() { + return slices; + } + + /** Seconds in a hot oven until the pizza is cooked. */ + public static int cookSeconds() { + return cookSeconds; + } + + /** Seconds in a hot oven, counted from when it went in, until the pizza burns. */ + public static int burnSeconds() { + return burnSeconds; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaHandler.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaHandler.java new file mode 100644 index 0000000..b492d5c --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaHandler.java @@ -0,0 +1,243 @@ +package net.tfminecraft.cooking.pizza; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +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.event.world.ChunkLoadEvent; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.Cooking; +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.cooking.utils.StationChunks; +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.PlacedFurnitureSlot; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +/** + * Builds pizzas on a peel (pizzas.yml). Dough on an empty peel starts one, a sauce can be spread straight on the dough + * and toppings go on top; an empty hand takes back the top layer. Carried into a lit oven (sneak and right-click with + * an empty hand, like a baking tray) the pizza bakes, and once it's baked an empty hand takes it off the peel a slice + * at a time. + */ +public final class PizzaHandler implements Listener { + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInteract(FurnitureInteractEvent event) { + Furniture f = event.getFurniture(); + if (!PizzaConfig.isPeel(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 peel, into the oven or out of it. + if (emptyHand && p.isSneaking()) return; + List slots = PizzaPeel.slotIds(f); + if (slots.isEmpty()) return; + List layers = PizzaPeel.layers(f); + FoodItem food = emptyHand ? null : FoodItem.fromItem(hand); + SandwichLayer layer = PizzaConfig.layerFor(food); + int baked = PizzaPeel.baked(f); + + if (layers.isEmpty()) { + // An empty peel is picked up with an empty hand; only food starts anything on it. + if (food == null) return; + event.setCancelled(true); + if (f.isAttached()) { + p.sendMessage("§7Take the peel out of the oven first."); + } else if (!PizzaConfig.isBase(layer)) { + p.sendMessage("§7Start a pizza with a piece of dough."); + } else { + PizzaPeel.setBake(f, 0, PizzaPeel.RAW); + place(p, f, slots.get(0), hand, food, layer); + } + return; + } + + if (emptyHand) { + if (baked > PizzaPeel.RAW) { + event.setCancelled(true); + serve(p, f, layers, baked); + } else if (!f.isAttached()) { + event.setCancelled(true); + takeTop(p, f, layers); + } + return; + } + + event.setCancelled(true); + if (f.isAttached()) { + p.sendMessage("§7Take the peel out of the oven first."); + } else if (baked > PizzaPeel.RAW) { + p.sendMessage("§7This pizza is baked; take a slice with an empty hand."); + } else if (layer == null) { + p.sendMessage("§7That doesn't go on a pizza."); + } else if (PizzaConfig.isBase(layer)) { + p.sendMessage("§7There's already dough on this peel."); + } else if (PizzaConfig.isSauce(layer)) { + if (layers.size() == 1) place(p, f, slots.get(1), hand, food, layer); + else if (PizzaConfig.isSauce(layerOf(layers.get(1)))) p.sendMessage("§7This pizza already has its sauce."); + else p.sendMessage("§7Spread the sauce before the toppings."); + } else if (toppings(layers) >= PizzaConfig.maxToppings() || layers.size() >= slots.size()) { + p.sendMessage("§7There's no room for more toppings."); + } else { + place(p, f, slots.get(layers.size()), hand, food, layer); + } + } + + /** A pizza left on a broken peel drops as its slices left when baked, and as its layers when not. */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBreak(FurnitureBreakEvent event) { + Furniture f = event.getFurniture(); + if (!PizzaConfig.isPeel(f)) return; + List layers = PizzaPeel.layers(f); + if (layers.isEmpty()) return; + int baked = PizzaPeel.baked(f); + ItemStack slices = baked > PizzaPeel.RAW ? slice(event.getPlayer(), f, layers, baked) : null; + if (slices != null) slices.setAmount(Math.max(1, PizzaPeel.left(f))); + for (int i = 0; i < layers.size(); i++) { + PlacedSlot slot = layers.get(i); + if (slices == null) slot.setCurrentItem(asHeld(slot.getCurrentItem())); + else slot.setCurrentItem(i == 0 ? slices : null); + } + PizzaPeel.clearBake(f); + } + + @EventHandler + public void onChunkLoad(ChunkLoadEvent event) { + Chunk chunk = event.getChunk(); + // InteractibleFurniture restores the chunk's furniture first. + Bukkit.getScheduler().runTaskLater(Cooking.plugin, () -> { + for (Furniture f : placed()) { + if (StationChunks.isIn(f, chunk)) relayout(f); + } + }, 2L); + } + + /** + * Stacks the layers of every loaded pizza again after a restart, then keeps them lying on their peels as the peels + * go into ovens, which draw them smaller, and back out. + */ + public void start() { + for (Furniture f : placed()) relayout(f); + Bukkit.getScheduler().runTaskTimer(Cooking.plugin, () -> { + for (Furniture f : placed()) eachPeel(f, PizzaPeel::restackIfResized); + }, 10L, 10L); + } + + private static Iterable placed() { + return new ArrayList<>(InteractibleFurniture.getInstance().getFurnitureManager().getPlacedFurniture().values()); + } + + private static void relayout(Furniture f) { + eachPeel(f, PizzaPeel::stack); + } + + /** The peel, and the peels held in it (an oven's cavity holds its peel). */ + private static void eachPeel(Furniture f, Consumer action) { + if (PizzaConfig.isPeel(f)) action.accept(f); + for (PlacedFurnitureSlot slot : f.getActiveFurnitureSlots().values()) { + if (slot.getNested() != null) eachPeel(slot.getNested(), action); + } + } + + private static void place(Player p, Furniture f, String slotId, ItemStack hand, FoodItem food, SandwichLayer layer) { + ItemStack one = hand.clone(); + one.setAmount(1); + f.getOrCreatePlacedSlot(slotId).forceModel(PizzaPeel.shown(f, one, food, layer)); + hand.setAmount(hand.getAmount() - 1); + p.getInventory().setItemInMainHand(hand); + PizzaPeel.stack(f); + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); + f.getLoc().getWorld().playSound(f.getLoc(), Sound.ENTITY_ITEM_FRAME_ADD_ITEM, 1f, 1f); + p.swingMainHand(); + } + + private static void takeTop(Player p, Furniture f, List layers) { + PlacedSlot top = layers.get(layers.size() - 1); + ItemStack held = asHeld(top.getCurrentItem()); + clear(f, top); + if (layers.size() == 1) PizzaPeel.clearBake(f); + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); + give(p, held); + f.getLoc().getWorld().playSound(f.getLoc(), Sound.ENTITY_ITEM_FRAME_REMOVE_ITEM, 1f, 1f); + p.swingMainHand(); + } + + /** Takes a slice off the baked pizza; the last one leaves the peel empty. */ + private static void serve(Player p, Furniture f, List layers, int baked) { + boolean first = PizzaPeel.left(f) == PizzaConfig.slices(); + ItemStack slice = slice(p, f, layers, baked); + if (slice == null) { + p.sendMessage("§cThis pizza can't be made right now."); + return; + } + int left = PizzaPeel.left(f) - 1; + if (left > 0) { + PizzaPeel.setLeft(f, left); + PizzaPeel.remodel(f); + } else { + for (PlacedSlot slot : layers) clear(f, slot); + PizzaPeel.clearBake(f); + } + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); + give(p, slice); + f.getLoc().getWorld().playSound(f.getLoc(), Sound.ENTITY_ITEM_PICKUP, 1f, 1f); + p.swingMainHand(); + // The pizza counts as cooked once, when it's first cut. + if (first) Bukkit.getPluginManager().callEvent(new DishCookedEvent(p, slice, "pizza")); + } + + /** One slice of the pizza: the one cut from it before, or a new one kept for the slices after it. */ + private static ItemStack slice(Player p, Furniture f, List layers, int baked) { + ItemStack kept = PizzaPeel.slice(f); + if (kept != null) return kept; + ItemStack slice = PizzaItems.build(p, PizzaPeel.items(layers), baked); + if (slice != null) PizzaPeel.keepSlice(f, slice); + return slice; + } + + private static int toppings(List layers) { + int count = 0; + for (PlacedSlot slot : layers) { + SandwichLayer layer = layerOf(slot); + if (!PizzaConfig.isBase(layer) && !PizzaConfig.isSauce(layer)) count++; + } + return count; + } + + private static SandwichLayer layerOf(PlacedSlot slot) { + return PizzaConfig.layerFor(FoodItem.fromItem(slot.getCurrentItem())); + } + + private static void clear(Furniture f, PlacedSlot slot) { + slot.clearModel(); + f.removeActiveSlot(slot.getId()); + } + + private static void give(Player p, ItemStack item) { + ItemStack rest = InventoryAdder.addItem(p, item); + if (rest != null) p.getWorld().dropItem(p.getLocation(), rest); + } + + /** A layer 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/pizza/PizzaItems.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java new file mode 100644 index 0000000..6a62201 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaItems.java @@ -0,0 +1,91 @@ +package net.tfminecraft.cooking.pizza; + +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.TagStep; +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.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; + +/** Turns the layers on a peel into a slice of pizza. */ +public final class PizzaItems { + private static final String COOKED_TRACK = "cooked"; + + private PizzaItems() {} + + /** + * A slice of the pizza these layers make, dough first, at this bake (PizzaPeel.COOKED or BURNT): quality, freshness + * and variety come from all of them, and the toppings name it (Cheese, Tomato and Basil Pizza Slice). Null when + * pizzas.yml's output makes no item. + */ + public static ItemStack build(Player player, List stacks, int baked) { + List layers = new ArrayList<>(); + for (ItemStack stack : stacks) { + FoodItem food = FoodItem.fromItem(stack); + if (food == null) continue; + // A layer ages on the peel as it would anywhere else. + food.updateAge(); + layers.add(food); + } + FoodParser.Result parsed = FoodParser.parse(PizzaConfig.output()); + if (layers.isEmpty() || parsed == null || parsed.template == null) return null; + FoodItem pizza = parsed.template; + CompositionResult composed = CompositionQualityResolver.compose(player, layers, CompositionContext.PIZZA); + CompositionApplier.apply(pizza, composed); + applyTotals(pizza, layers); + for (FoodItem food : layers) { + SandwichLayer layer = PizzaConfig.layerFor(food); + if (layer == null || PizzaConfig.isBase(layer) || PizzaConfig.isSauce(layer)) continue; + // A topping named "" (salt) doesn't name the pizza. + String name = layer.fillerName(food); + if (!name.isEmpty()) pizza.addIngredient(name); + } + TagTrack cooked = TrackLoader.getByString(COOKED_TRACK); + if (cooked != null) { + TagTrack bake = new TagTrack(cooked); + bake.setValue(baked); + pizza.addOrModifyTrack(bake); + } + return ItemBuilder.buildComposedWithQuality(pizza, composed.getFinalQuality()); + } + + /** + * The dough becomes the crust, which feeds what the slice type does in types.yml; each other layer adds its own + * food shared between the slices, and nutrition is the average weighted by food. The pizza's bake then applies to + * all of it, so a layer's own cooking (fried sausage) doesn't count twice. + */ + static void applyTotals(FoodItem pizza, List layers) { + double food = pizza.getBaseFood(); + double nutrition = food * pizza.getBaseNutrition(); + for (FoodItem layer : layers) { + if (PizzaConfig.isBase(PizzaConfig.layerFor(layer))) continue; + double part = layer.getBaseFood() * kept(layer, true) / PizzaConfig.slices(); + food += part; + nutrition += part * layer.getBaseNutrition() * kept(layer, false); + } + pizza.setBaseFood(food); + if (food > 0) pizza.setBaseNutrition(nutrition / food); + } + + /** What a layer's lasting tags do to it; its age becomes the pizza's freshness and its cooking the pizza's bake. */ + private static double kept(FoodItem layer, boolean forFood) { + double multiplier = 1; + for (TagTrack track : layer.getTagTracks()) { + if (track.isAgeable() || COOKED_TRACK.equals(track.getId())) continue; + TagStep step = track.getCurrentStep(); + if (step != null) multiplier *= forFood ? step.getFoodMultiplier() : step.getNutritionMultiplier(); + } + return multiplier; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/pizza/PizzaPeel.java b/src/main/java/net/tfminecraft/cooking/pizza/PizzaPeel.java new file mode 100644 index 0000000..58956c9 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/pizza/PizzaPeel.java @@ -0,0 +1,174 @@ +package net.tfminecraft.cooking.pizza; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.ItemDisplay; +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; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; + +/** + * A pizza lying on a peel: its layers in the peel's pizza_N slots, bottom first, how far it has baked and how many of + * its slices have been taken (kept in the peel's variables so they survive restarts and trips in and out of the oven). + */ +public final class PizzaPeel { + static final String SLOT_PREFIX = "pizza_"; + static final String ELAPSED = "pizza.elapsed"; + static final String BAKED = "pizza.baked"; + static final String CUT = "pizza.cut"; + static final String SLICE = "pizza.slice"; + public static final int RAW = 0; + public static final int COOKED = 1; + public static final int BURNT = 2; + private static final List STATE_TAGS = List.of("raw", "cooked", "burnt"); + // The size each peel's layers were last stacked at. + private static final Map stackedAt = new HashMap<>(); + + private PizzaPeel() {} + + /** The peel's pizza slots, bottom first. */ + static List slotIds(Furniture f) { + List ids = new ArrayList<>(); + for (String id : f.getType().getSlots().keySet()) { + if (id.startsWith(SLOT_PREFIX)) ids.add(id); + } + ids.sort(Comparator.comparingInt(PizzaPeel::level)); + return ids; + } + + private static int level(String slotId) { + try { + return Integer.parseInt(slotId.substring(SLOT_PREFIX.length())); + } catch (NumberFormatException e) { + return Integer.MAX_VALUE; + } + } + + /** The layers lying on the peel, bottom first. */ + static List layers(Furniture f) { + List layers = new ArrayList<>(); + for (String id : slotIds(f)) { + PlacedSlot slot = f.getActiveSlot(id).orElse(null); + if (slot != null && slot.getCurrentItem() != null) layers.add(slot); + } + return layers; + } + + static List items(List layers) { + List items = new ArrayList<>(); + for (PlacedSlot slot : layers) items.add(slot.getCurrentItem()); + return items; + } + + /** RAW, COOKED or BURNT. */ + public static int baked(Furniture f) { + return number(f, BAKED); + } + + /** Seconds the pizza 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 pizza: its bake, its cuts and its slice. */ + static void clearBake(Furniture f) { + f.getVariables().remove(ELAPSED); + f.getVariables().remove(BAKED); + f.getVariables().remove(CUT); + f.getVariables().remove(SLICE); + } + + /** Slices still on the peel. */ + static int left(Furniture f) { + return Math.max(0, PizzaConfig.slices() - number(f, CUT)); + } + + static void setLeft(Furniture f, int left) { + f.getVariables().put(CUT, PizzaConfig.slices() - left); + } + + /** The slice cut from this pizza at its bake, kept so every slice is the same; null before the first cut. */ + static ItemStack slice(Furniture f) { + return f.getVariables().get(SLICE) instanceof String encoded ? PotReference.decodeStack(encoded) : null; + } + + static void keepSlice(Furniture f, ItemStack slice) { + String encoded = PotReference.encodeStack(slice); + if (encoded != null) f.getVariables().put(SLICE, encoded); + } + + /** Baking further changes the slices still to be cut. */ + static void forgetSlice(Furniture f) { + f.getVariables().remove(SLICE); + } + + /** + * The layer's look on the peel at the pizza's bake (raw, cooked or burnt) and with its slices taken: a model with + * _left3 for three slices left, falling back to the whole one (pizzas.yml models). + */ + static ItemStack shown(Furniture f, ItemStack one, FoodItem food, SandwichLayer layer) { + int left = left(f); + String cut = left < PizzaConfig.slices() ? "_left" + left : ""; + ItemStack shown = layer.model(food, one, List.of(STATE_TAGS.get(baked(f))), cut); + return shown == null ? one : shown; + } + + /** Shows every layer as it looks at the pizza's bake and cuts. */ + static void remodel(Furniture f) { + for (PlacedSlot slot : layers(f)) { + ItemStack item = slot.getCurrentItem(); + FoodItem food = FoodItem.fromItem(item); + SandwichLayer layer = PizzaConfig.layerFor(food); + if (layer != null) slot.forceModel(shown(f, item, food, layer)); + } + } + + /** How big the peel is drawn: 1 on its own, smaller in an oven's cavity. */ + static float scale(Furniture f) { + return Bukkit.getEntity(f.getEntityId()) instanceof ItemDisplay display + ? display.getTransformation().getScale().y() : 1f; + } + + /** + * Lifts each layer onto the one below it; the layer models are drawn lying on the peel. The lift is a distance in + * the world, so it shrinks with the peel (InteractibleFurniture scales the layers' models, not their lift). + */ + static void stack(Furniture f) { + float scale = scale(f); + stackedAt.put(f.getEntityId(), scale); + double height = 0; + for (PlacedSlot slot : layers(f)) { + DisplayData data = new DisplayData(); + data.setyPos((float) (height / 16 * scale)); + slot.applyDisplayData(data); + SandwichLayer layer = PizzaConfig.layerFor(FoodItem.fromItem(slot.getCurrentItem())); + height += layer == null ? 0 : layer.getThickness(); + } + } + + /** Stacks the layers again when the peel is drawn at another size than they were stacked at (in or out of an oven). */ + static void restackIfResized(Furniture f) { + Float at = stackedAt.get(f.getEntityId()); + if (at == null || at != scale(f)) stack(f); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java index 60404d9..66c1434 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java +++ b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java @@ -10,6 +10,7 @@ public enum CompositionContext { FRYING_PAN(true), SAUSAGE_MAKER(true), SANDWICH(true), + PIZZA(true), MILLING(false), BAKING(false); diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichLayer.java b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichLayer.java index 812b638..ffd55bb 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichLayer.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichLayer.java @@ -1,5 +1,6 @@ package net.tfminecraft.cooking.sandwich; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -14,7 +15,10 @@ import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.utils.ItemRef; -/** One kind of sandwich layer from sandwiches.yml: the foods it takes, how it lies on the board and the name it adds. */ +/** + * One kind of sandwich layer from sandwiches.yml: the foods it takes, how it lies on the board and the name it adds. + * Pizzas (pizzas.yml) describe their layers the same way. + */ public final class SandwichLayer { private final String id; private final Set types = new HashSet<>(); @@ -75,15 +79,35 @@ public String fillerName(FoodItem food) { * {origin} becomes the origin in lower case with _ for spaces (melon_slice). Null when none exists. */ public ItemStack model(FoodItem food, ItemStack source) { - List tags = food.getCurrentTagIds(); + return model(food, source, List.of()); + } + + /** As {@link #model(FoodItem, ItemStack)}, with extra tags that count as the food's own (a pizza's bake). */ + public ItemStack model(FoodItem food, ItemStack source, List extraTags) { + return model(food, source, extraTags, ""); + } + + /** + * As {@link #model(FoodItem, ItemStack, List)}, trying each model with a suffix first (a cut pizza's _left3) and + * then as it is. + */ + public ItemStack model(FoodItem food, ItemStack source, List extraTags, String suffix) { + List tags = new ArrayList<>(food.getCurrentTagIds()); + tags.addAll(extraTags); String origin = food.getOrigin() == null ? "none" : food.getOrigin().toLowerCase(Locale.ROOT).replace(' ', '_'); for (String entry : models) { String[] parts = entry.trim().split("\\s+", 2); if (parts.length == 2 && !tags.contains(parts[0])) continue; - ItemStack shown = ItemRef.apply(parts[parts.length - 1].replace("{origin}", origin), source); - // TLibs hands out dirt for an ItemsAdder item that doesn't exist. - if (shown != null && !shown.getType().isAir() && shown.getType() != Material.DIRT) return shown; + String path = parts[parts.length - 1].replace("{origin}", origin); + ItemStack shown = suffix.isEmpty() ? null : existing(ItemRef.apply(path + suffix, source)); + if (shown == null) shown = existing(ItemRef.apply(path, source)); + if (shown != null) return shown; } return null; } + + // TLibs hands out dirt for an ItemsAdder item that doesn't exist. + private static ItemStack existing(ItemStack shown) { + return shown != null && !shown.getType().isAir() && shown.getType() != Material.DIRT ? shown : null; + } } diff --git a/src/main/resources/pizzas.yml b/src/main/resources/pizzas.yml new file mode 100644 index 0000000..3a02f16 --- /dev/null +++ b/src/main/resources/pizzas.yml @@ -0,0 +1,123 @@ +# Pizzas, built on a pizza peel and baked in the oven: +# 1. Place a pizza peel and right-click it with a piece of dough. +# 2. Right-click with a sauce to spread it straight on the dough (optional). +# 3. Right-click with toppings to lay them on it (an empty hand takes the top one back off). +# 4. Sneak and right-click with an empty hand to carry the peel, and put it in a lit oven like a baking tray. +# 5. Once it's baked, right-click the peel with an empty hand to take a slice; the last slice leaves the peel empty. +# A slice's food is its crust's (the output type's food in types.yml, in place of the dough's) plus its share of what +# the other layers hold, its nutrition their average (weighted by food), and its quality, freshness and variety come +# from all of them (composition.yml context PIZZA). It takes the bake (cooked or burnt) as its cooked tag. The toppings +# name it: "Cheese, Tomato and Basil Pizza Slice" (naming.yml fillers max). Every slice of a pizza is the same, so +# they stack. +# +# The peel needs slots pizza_1, pizza_2, ... (InteractibleFurniture), one per layer, at the furniture's centre with +# scale 1 and interactible: false. Each layer model is drawn lying on the peel, in its coordinates; Cooking lifts each +# layer by the thickness of those below it. The peel also needs carry: true to go in the oven. + +# Furniture ids pizzas are built on. +furniture: + - pizza_peel + +# Toppings on a pizza, at most (and no more than the peel's pizza slots allow after the dough and sauce). +max-toppings: 6 + +# One slice of the finished pizza (types.yml). Its food and nutrition are its crust's. +output: meal(type=pizza_slice;origin=Mixed) + +# Slices a baked pizza is cut into. While some are taken, each layer shows its model with _left3 (slices left) added +# when that exists. 1 takes the pizza off whole (then make output a whole pizza). +slices: 4 + +# Seconds in a lit oven until the pizza is cooked, and until it burns (counted from when it went in). +bake: + cook-seconds: 20 + burn-seconds: 60 + +# 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) +# thickness how high the layer lies, in model pixels (1/16 block) +# models its look on the peel: a model, or a tag and a model used while the food has that tag. The pizza's +# bake counts as a tag too (raw, cooked, burnt). The first that exists is used, so later lines are +# fallbacks. {origin} is the origin in lower case with _ for spaces (melon_slice). +# The dough every pizza starts with. +base: + types: + - dough + thickness: 1.0 + models: + - rotten ia.tfmc_cooking:pizza_base_rotten + - burnt ia.tfmc_cooking:pizza_base_burnt + - cooked ia.tfmc_cooking:pizza_base_cooked + - ia.tfmc_cooking:pizza_base_raw + +# One sauce, spread straight on the dough before any topping. It doesn't name the pizza. +sauce: + types: + - paste + thickness: 0.2 + models: + - burnt ia.tfmc_cooking:pizza_sauce_burnt + - ia.tfmc_cooking:pizza_sauce_{origin} + - ia.tfmc_cooking:pizza_sauce_tomato + +# The first topping that takes a food wins. +toppings: + cheese: + types: + - cheese + name: Cheese + names: + goat: Goat Cheese + thickness: 0.3 + models: + - burnt ia.tfmc_cooking:pizza_cheese_burnt + - cooked ia.tfmc_cooking:pizza_cheese_{origin}_melted + - cooked ia.tfmc_cooking:pizza_cheese_melted + - ia.tfmc_cooking:pizza_cheese_{origin} + - ia.tfmc_cooking:pizza_cheese + vegetable: + types: + - vegetable_cut + - vegetable_cut_2 + - vegetable_chopped + - vegetable_chopped_2 + - garnish_chopped + name: "{origin}" + thickness: 0.05 + models: + - rotten ia.tfmc_cooking:pizza_topping_{origin}_rotten + - burnt ia.tfmc_cooking:pizza_topping_{origin}_burnt + - ia.tfmc_cooking:pizza_topping_{origin} + fruit: + types: + - fruit_1 + - fruit_2 + name: "{origin}" + names: + melon slice: Melon + thickness: 0.05 + models: + - rotten ia.tfmc_cooking:pizza_topping_{origin}_rotten + - burnt ia.tfmc_cooking:pizza_topping_{origin}_burnt + - ia.tfmc_cooking:pizza_topping_{origin} + sausage: + types: + - sausage + name: Sausage + thickness: 0.05 + models: + - rotten ia.tfmc_cooking:pizza_topping_sausage_rotten + - burnt ia.tfmc_cooking:pizza_topping_sausage_burnt + - ia.tfmc_cooking:pizza_topping_sausage + spice: + types: + - spice_1 + - seasoning_1 + name: "{origin}" + names: + salt: "" + thickness: 0.05 + models: + - burnt ia.tfmc_cooking:pizza_topping_{origin}_burnt + - ia.tfmc_cooking:pizza_topping_{origin} diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index 3a49cf7..5825e74 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -21,6 +21,8 @@ import net.tfminecraft.cooking.fishing.CustomFishingCatalog; import net.tfminecraft.cooking.fishing.LegacyFishScan; import net.tfminecraft.cooking.hook.MeatHookHandler; +import net.tfminecraft.cooking.pizza.PizzaConfig; +import net.tfminecraft.cooking.pizza.PizzaHandler; import net.tfminecraft.cooking.sandwich.SandwichConfig; import net.tfminecraft.cooking.sandwich.SandwichHandler; import net.tfminecraft.cooking.husbandry.*; @@ -82,7 +84,7 @@ class CookingLifecycleTest { MillingRecipeLoader.class, BakingTrayHandler.class, MillingStoneHandler.class, TroughHandler.class, TroughAging.class, BakingTrayAging.class, OvenCavityManager.class, OvenBurnManager.class, OvenLifecycleHandler.class, OvenHandler.class, CraftingManager.class, - SandwichConfig.class, SandwichHandler.class)) { + SandwichConfig.class, SandwichHandler.class, PizzaConfig.class, PizzaHandler.class)) { constructions.put(type, mockConstruction(type)); } items = mock(ItemAPI.class); @@ -122,7 +124,7 @@ class CookingLifecycleTest { for (String name : List.of("models.yml", "types.yml", "crafting-stations.yml", "baking-trays.yml", "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")) { + "crops.yml", "husbandry.yml", "custom-fishing.yml", "sandwiches.yml", "pizzas.yml")) { Path resource = plugin.getDataFolder().toPath().resolve(name); assertTrue(Files.isRegularFile(resource), name); assertFalse(Files.readString(resource).isBlank(), name); @@ -133,6 +135,7 @@ class CookingLifecycleTest { assertConfigLoaded(plugin, FoodLoader.class, "types.yml"); assertConfigLoaded(plugin, BakingTrayLoader.class, "baking-trays.yml"); assertConfigLoaded(plugin, SandwichConfig.class, "sandwiches.yml"); + assertConfigLoaded(plugin, PizzaConfig.class, "pizzas.yml"); assertConfigLoaded(plugin, MillingRecipeLoader.class, "milling-recipes.yml"); assertConfigLoaded(plugin, CraftingStationLoader.class, "crafting-stations.yml"); assertConfigLoaded(plugin, TrackLoader.class, "tags.yml"); @@ -203,6 +206,7 @@ class CookingLifecycleTest { verify(built(LiquidContainerHandler.class)).resumeLoadedContainers(); verify(built(MeatHookHandler.class)).resumeLoadedHooks(); verify(built(SandwichHandler.class)).resumeLoaded(); + verify(built(PizzaHandler.class)).start(); saturation.verify(SaturationGuard::start); nutrition.verify(NutritionDrainTask::start); husbandry.verify(HusbandryLifecycleListener::resumeLoadedWorlds); diff --git a/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java index 0a59c7f..2459f7c 100644 --- a/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java @@ -869,7 +869,9 @@ public Environment() { doAnswer(inv -> { active.remove(inv.getArgument(0)); return null; }).when(furniture).removeActiveSlot(anyString()); for (String id : List.of("decoration", "input_1", "input_2", "input_3", "input_4", "input_5")) define(id); placed.put(furniture.getEntityId(), furniture); - scoped(mockStatic(HeatSources.class)).when(() -> HeatSources.stationHasHeat(any())).thenAnswer(inv -> heated); + MockedStatic heat = scoped(mockStatic(HeatSources.class)); + heat.when(() -> HeatSources.stationHasHeat(any())).thenAnswer(inv -> heated); + heat.when(() -> HeatSources.consumerHasHeat(any())).thenAnswer(inv -> heated); scoped(mockStatic(FurnitureCache.class)).when(() -> FurnitureCache.isFirePit(any())).thenAnswer(inv -> firePit); foodCodec = scoped(mockStatic(FoodItem.class, CALLS_REAL_METHODS)); foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(inv -> resolve(inv.getArgument(0))); diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index e04dd77..19476b9 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", "MILLING", "BAKING"); + "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "SANDWICH", "PIZZA", "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"), 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 6fc218f..2c8297f 100644 --- a/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java @@ -4,6 +4,8 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Random; @@ -21,6 +23,7 @@ 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.baking.BakingTrayCoverageTest.Bakery; import net.tfminecraft.cooking.baking.BakingTrayState; @@ -28,6 +31,8 @@ import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.heat.HeatSources; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.pizza.PizzaConfig; +import net.tfminecraft.cooking.pizza.PizzaPeel; import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; import net.tfminecraft.cooking.utils.ItemUpdater; @@ -241,12 +246,34 @@ 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 can go in the oven.")); + assertTrue(env.messages.contains("§cOnly baking trays 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()); } + @Test + void cavitiesTakePizzaPeelsAndBakeTheirPizzas(@TempDir Path directory) throws Exception { + Path config = directory.resolve("pizzas.yml"); + Files.writeString(config, "furniture: [pizza_peel]\nbake:\n cook-seconds: 1\n burn-seconds: 2\nbase:\n types: [dough]\n"); + new PizzaConfig().load(config.toFile()); + try { + Station peel = bakery.station("pizza_peel"); peel.define("pizza_1"); + var slot = cavity.define("tray"); when(slot.getSlotType()).thenReturn(SlotType.FURNITURE); + var allowed = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, peel.furniture); + new OvenCavityHandler().onFurnitureAdd(allowed); assertFalse(allowed.isCancelled()); + + peel.place("pizza_1", env.stack(bakery.food("dough", null), Material.WHEAT, 1)); + bakery.nest(cavity, "peel", peel.furniture); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(20); + assertEquals(1, peel.variables.get("pizza.elapsed")); + assertEquals(PizzaPeel.COOKED, PizzaPeel.baked(peel.furniture)); + verify(env.manager).markDirty(peel.furniture); + } finally { + new PizzaConfig().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/pizza/PizzaCoverageTest.java b/src/test/java/net/tfminecraft/cooking/pizza/PizzaCoverageTest.java new file mode 100644 index 0000000..cb58c64 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/pizza/PizzaCoverageTest.java @@ -0,0 +1,557 @@ +package net.tfminecraft.cooking.pizza; + +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 java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; +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.ArgumentCaptor; +import org.mockito.MockedStatic; + +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.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.PlacedFurnitureSlot; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; + +class PizzaCoverageTest { + static final String CONFIG = """ + furniture: [BOARD] + max-toppings: 2 + output: meal(type=pizza) + slices: 2 + bake: + cook-seconds: 2 + burn-seconds: 4 + base: + types: [dough] + thickness: 1.0 + models: + - burnt ia.base_burnt + - cooked ia.base_cooked + - ia.base_raw + sauce: + types: [paste] + thickness: 0.2 + models: + - ia.sauce_{origin} + toppings: + cheese: + types: [cheese] + name: Cheese + thickness: 0.3 + spice: + types: [spice_1] + names: + salt: "" + thickness: 0.05 + plain: 3 + """; + + Environment env; + MockedStatic adder; + final List modelRefs = new ArrayList<>(); + final List given = new ArrayList<>(); + final Map variables = new HashMap<>(); + ItemStack rest; + FoodItem built; + int builtQuality; + final PizzaHandler handler = new PizzaHandler(); + @TempDir Path directory; + + @BeforeEach void open() throws Exception { + env = new Environment(); + for (String id : List.of("pizza_2", "pizza_1", "pizza_3", "pizza_4", "pizza_5", "pizza_x")) env.define(id); + 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); + return new ItemStack(ref.startsWith("ia.sauce") ? Material.RED_DYE : Material.STICK); + }); + env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(inv -> { + built = inv.getArgument(0); + builtQuality = inv.getArgument(1); + 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; + }); + load(CONFIG); + } + + @AfterEach void close() throws Exception { + new PizzaConfig().load(directory.resolve("missing.yml").toFile()); + env.close(); + } + + void load(String yaml) throws Exception { + Path file = directory.resolve("pizzas.yml"); + Files.writeString(file, yaml); + new PizzaConfig().load(file.toFile()); + } + + PlacedSlot slot(String id, ItemStack item) { + PlacedSlot slot = env.place(id, item); + when(slot.getId()).thenReturn(id); + return slot; + } + + ItemStack dough() { + FoodItem dough = env.food("dough", "Wheat"); + dough.setBaseFood(4); + dough.setBaseNutrition(4); + return env.stack(dough, Material.WHEAT, 4); + } + + ItemStack paste(String origin) { + FoodItem paste = env.food("paste", origin); + paste.setBaseFood(12); + paste.setBaseNutrition(8); + return env.stack(paste, Material.RED_DYE, 4); + } + + ItemStack cheese() { + FoodItem cheese = env.food("cheese", "Cow"); + cheese.setBaseFood(12); + cheese.setBaseNutrition(8); + return env.stack(cheese, Material.YELLOW_WOOL, 4); + } + + ItemStack spice(String origin) { + return env.stack(env.food("spice_1", origin), Material.GREEN_DYE, 4); + } + + void hold(ItemStack item) { + env.player.getInventory().setItemInMainHand(item); + } + + FurnitureInteractEvent click(ItemStack held) { + hold(held); + FurnitureInteractEvent event = new FurnitureInteractEvent(env.player, env.furniture); + handler.onInteract(event); + return event; + } + + ItemStack layer(int level) { + PlacedSlot slot = env.active.get("pizza_" + level); + return slot == null ? null : slot.getCurrentItem(); + } + + float lift(int level) { + return lift("pizza_" + level); + } + + float lift(String id) { + ArgumentCaptor data = ArgumentCaptor.forClass(DisplayData.class); + verify(env.active.get(id), atLeastOnce()).applyDisplayData(data.capture()); + return data.getValue().getyPos(); + } + + 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; + } + + @Test + void configReadsPeelsLayersAndBakeAndFallsBackToDefaults() throws Exception { + assertTrue(PizzaConfig.isPeel(env.furniture)); + assertFalse(PizzaConfig.isPeel(null)); + assertFalse(PizzaConfig.isPeel(mock(Furniture.class))); + assertEquals(2, PizzaConfig.maxToppings()); + assertEquals("meal(type=pizza)", PizzaConfig.output()); + assertEquals(2, PizzaConfig.slices()); + assertEquals(2, PizzaConfig.cookSeconds()); + assertEquals(4, PizzaConfig.burnSeconds()); + assertTrue(PizzaConfig.isBase(PizzaConfig.layerFor(env.food("dough", "Wheat")))); + assertTrue(PizzaConfig.isSauce(PizzaConfig.layerFor(env.food("paste", "Tomato")))); + assertEquals("cheese", PizzaConfig.layerFor(env.food("cheese", "Cow")).getId()); + assertFalse(PizzaConfig.isBase(PizzaConfig.layerFor(env.food("cheese", "Cow")))); + assertFalse(PizzaConfig.isSauce(null)); + assertFalse(PizzaConfig.isBase(null)); + assertNull(PizzaConfig.layerFor(env.food("beef", "Cow"))); + assertNull(PizzaConfig.layerFor(null)); + + new PizzaConfig().load(directory.resolve("missing.yml").toFile()); + assertFalse(PizzaConfig.isPeel(env.furniture)); + assertEquals(6, PizzaConfig.maxToppings()); + assertEquals("meal(type=pizza_slice;origin=Mixed)", PizzaConfig.output()); + assertEquals(4, PizzaConfig.slices()); + assertEquals(20, PizzaConfig.cookSeconds()); + assertEquals(60, PizzaConfig.burnSeconds()); + assertNull(PizzaConfig.layerFor(env.food("dough", "Wheat"))); + + // A pizza can't burn before it's cooked. + load("max-toppings: 0\nslices: 0\nbake:\n cook-seconds: 30\n burn-seconds: 10\n"); + assertEquals(1, PizzaConfig.maxToppings()); + assertEquals(1, PizzaConfig.slices()); + assertEquals(31, PizzaConfig.burnSeconds()); + } + + @Test + void pizzasAddTheirToppingsToTheCrustAndAreNamedByThem() { + FoodItem fried = env.food("cheese", "Cow"); + fried.setBaseFood(10); + fried.setBaseNutrition(2); + TagTrack cooked = new TagTrack(TrackLoader.getByString("cooked")); + cooked.setValue(1); + fried.addOrModifyTrack(cooked); + fried.addOrModifyTrack(new TagTrack("plain", false, List.of())); + fried.addOrModifyTrack(new TagTrack(TrackLoader.getByString("freshness"))); + ItemStack pizza = PizzaItems.build(env.player, List.of(dough(), paste("Tomato"), new ItemStack(Material.STONE), + cheese(), spice("Basil"), spice("Salt"), env.stack(fried, Material.YELLOW_WOOL, 1), env.raw(1)), + PizzaPeel.COOKED); + assertNotNull(pizza); + assertEquals(72, builtQuality); + assertEquals("meal(type=pizza)", built.getId()); + assertEquals(List.of("Cheese", "Basil"), built.getIngredients()); + assertEquals(PizzaPeel.COOKED, built.getTagTrack("cooked").getValue()); + // The slice's crust (the template's 1) in place of the dough, then its half of paste 12, cheese 12, the spices, + // beef and the fried cheese 1 each but the fried one's 10, its own cooking left out. + double food = 1 + (12 + 12 + 1 + 1 + 10 + 1) / 2.0; + assertEquals(food, built.getBaseFood(), 1e-9); + double nutrition = 1 + (12 * 8 + 12 * 8 + 1 + 1 + 10 * 2 + 1) / 2.0; + assertEquals(nutrition / food, built.getBaseNutrition(), 1e-9); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), argThat(l -> l.size() == 7), + eq(CompositionContext.PIZZA))); + + env.parser.when(() -> FoodParser.parse(anyString())).thenAnswer(inv -> { + FoodParser.Result result = new FoodParser.Result(); + result.template = env.food("pizza", "Mixed"); + result.template.setBaseFood(0); + return result; + }); + FoodItem empty = env.food("spice_1", "Salt"); + empty.setBaseFood(0); + List tracks = TrackLoader.oList; + TrackLoader.oList = new ArrayList<>(); + try { + PizzaItems.build(null, List.of(env.stack(empty, Material.GREEN_DYE, 1)), PizzaPeel.BURNT); + } finally { + TrackLoader.oList = tracks; + } + assertEquals(0, built.getBaseFood()); + assertNull(built.getTagTrack("cooked")); + assertTrue(built.getIngredients().isEmpty()); + + assertNull(PizzaItems.build(env.player, List.of(new ItemStack(Material.STONE)), PizzaPeel.COOKED)); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(new FoodParser.Result()); + assertNull(PizzaItems.build(env.player, List.of(dough()), PizzaPeel.COOKED)); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + assertNull(PizzaItems.build(env.player, List.of(dough()), PizzaPeel.COOKED)); + } + + @Test + void anEmptyPeelOnlyTakesDoughAndLeavesEverythingElseToTheFurniture() { + hold(dough()); + FurnitureInteractEvent elsewhere = new FurnitureInteractEvent(env.player, other()); + handler.onInteract(elsewhere); + assertFalse(elsewhere.isCancelled()); + + // An empty hand picks the empty peel up; something that isn't food is left alone. + assertFalse(click(null).isCancelled()); + assertFalse(click(new ItemStack(Material.STONE)).isCancelled()); + + assertTrue(click(cheese()).isCancelled()); + verify(env.player).sendMessage("§7Start a pizza with a piece of dough."); + assertNull(layer(1)); + + when(env.furniture.isAttached()).thenReturn(true); + assertTrue(click(dough()).isCancelled()); + verify(env.player).sendMessage("§7Take the peel out of the oven first."); + assertNull(layer(1)); + + env.definitions.keySet().removeIf(id -> id.startsWith("pizza_")); + assertFalse(click(dough()).isCancelled()); + } + + @Test + void sneakingWithAnEmptyHandCarriesThePeel() { + slot("pizza_1", dough()); + when(env.player.isSneaking()).thenReturn(true); + assertFalse(click(null).isCancelled()); + assertTrue(click(cheese()).isCancelled()); + } + + @Test + void doughSauceAndToppingsStackUpAndAnEmptyHandTakesTheTopOneBack() { + variables.put(PizzaPeel.BAKED, "not a number"); + assertTrue(click(dough()).isCancelled()); + assertEquals(3, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(Material.STICK, layer(1).getType()); + assertEquals("dough", env.resolve(layer(1)).getId()); + assertEquals(List.of("ia.base_raw"), modelRefs); + assertEquals(0f, lift(1)); + assertEquals(0, variables.get(PizzaPeel.BAKED)); + + click(dough()); + verify(env.player).sendMessage("§7There's already dough on this peel."); + click(new ItemStack(Material.STONE)); + verify(env.player).sendMessage("§7That doesn't go on a pizza."); + + click(paste("Tomato")); + assertEquals(Material.RED_DYE, layer(2).getType()); + assertTrue(modelRefs.contains("ia.sauce_tomato")); + assertEquals(1f / 16, lift(2), 1e-6); + click(paste("Carrot")); + verify(env.player).sendMessage("§7This pizza already has its sauce."); + + // Cheese has no peel model, so it lies as it looks in hand. + click(cheese()); + assertEquals(Material.YELLOW_WOOL, layer(3).getType()); + assertEquals(1, layer(3).getAmount()); + assertEquals(1.2f / 16, lift(3), 1e-6); + + click(paste("Carrot")); + verify(env.player, times(2)).sendMessage("§7This pizza already has its sauce."); + click(spice("Basil")); + assertEquals(1.5f / 16, lift(4), 1e-6); + click(spice("Basil")); + verify(env.player).sendMessage("§7There's no room for more toppings."); + assertNull(layer(5)); + + // An empty hand takes the top layer back, as it looks in hand. + click(null); + assertNull(layer(4)); + assertEquals("spice_1", env.resolve(given.get(0)).getId()); + env.updater.verify(() -> ItemUpdater.applyItemUpdate(any(), any(), isNull())); + click(null); + click(null); + assertEquals(0, variables.get(PizzaPeel.BAKED)); + click(null); + assertNull(layer(1)); + assertTrue(variables.isEmpty()); + assertEquals(4, given.size()); + } + + @Test + void sauceGoesStraightOnTheDoughAndToppingsStopAtTheLastSlot() throws Exception { + click(dough()); + click(cheese()); + click(paste("Tomato")); + verify(env.player).sendMessage("§7Spread the sauce before the toppings."); + + load(CONFIG.replace("max-toppings: 2", "max-toppings: 9")); + env.definitions.remove("pizza_4"); + env.definitions.remove("pizza_5"); + env.definitions.remove("pizza_x"); + click(cheese()); + assertNotNull(layer(3)); + click(cheese()); + verify(env.player).sendMessage("§7There's no room for more toppings."); + } + + @Test + void aBakedPizzaIsServedASliceAtATimeAndTakesNothingMore() { + slot("pizza_1", dough()); + slot("pizza_2", paste("Tomato")); + slot("pizza_3", cheese()); + PizzaPeel.setBake(env.furniture, 3, PizzaPeel.COOKED); + + click(cheese()); + verify(env.player).sendMessage("§7This pizza is baked; take a slice with an empty hand."); + assertNotNull(layer(3)); + + // Even in the oven, an empty hand takes a slice, and the rest of the pizza shows the slice gone. + when(env.furniture.isAttached()).thenReturn(true); + click(cheese()); + verify(env.player, times(1)).sendMessage("§7Take the peel out of the oven first."); + assertTrue(click(null).isCancelled()); + assertEquals(1, given.size()); + assertEquals(Material.PUMPKIN_PIE, given.get(0).getType()); + assertEquals(List.of("Cheese"), built.getIngredients()); + assertEquals(1, PizzaPeel.left(env.furniture)); + assertTrue(modelRefs.contains("ia.base_cooked_left1")); + assertTrue(modelRefs.contains("ia.sauce_tomato_left1")); + assertNotNull(layer(3)); + env.server.getPluginManager().assertEventFired(DishCookedEvent.class); + env.server.getPluginManager().clearEvents(); + + // The last slice is the same as the first, and leaves the peel empty. + FoodItem first = built; + assertTrue(click(null).isCancelled()); + assertEquals(2, given.size()); + assertTrue(given.get(1).isSimilar(given.get(0))); + assertSame(first, built); + assertTrue(env.active.isEmpty()); + assertTrue(variables.isEmpty()); + assertThrows(AssertionError.class, + () -> env.server.getPluginManager().assertEventFired(DishCookedEvent.class)); + } + + @Test + void aRawPizzaInTheOvenIsLeftToTheFurniture() { + slot("pizza_1", dough()); + when(env.furniture.isAttached()).thenReturn(true); + assertFalse(click(null).isCancelled()); + assertNotNull(layer(1)); + } + + @Test + void aPizzaThatCannotBeMadeStaysOnThePeel() { + slot("pizza_1", dough()); + PizzaPeel.setBake(env.furniture, 3, PizzaPeel.BURNT); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + click(null); + verify(env.player).sendMessage("§cThis pizza can't be made right now."); + assertNotNull(layer(1)); + assertTrue(given.isEmpty()); + } + + @Test + void whatDoesNotFitInThePlayersInventoryDropsAtTheirFeet() { + rest = new ItemStack(Material.WHEAT); + click(dough()); + click(null); + assertEquals(1, env.dropped.size()); + } + + @Test + void aBrokenPeelDropsABakedPizzaWholeAndARawOneAsItsLayers() { + handler.onBreak(new FurnitureBreakEvent(other(), env.player)); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + + ItemStack dough = dough(); + slot("pizza_1", dough); + slot("pizza_2", new ItemStack(Material.STONE)); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertNotSame(dough, layer(1)); + assertSame(env.resolve(dough), env.resolve(layer(1))); + assertEquals(Material.STONE, layer(2).getType()); + + ItemStack held = layer(1); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + handler.onBreak(new FurnitureBreakEvent(env.furniture, null)); + assertSame(held, layer(1)); + + // A baked pizza drops as the slices left on it. + env.active.clear(); + slot("pizza_1", dough()); + slot("pizza_2", cheese()); + PizzaPeel.setBake(env.furniture, 3, PizzaPeel.COOKED); + handler.onBreak(new FurnitureBreakEvent(env.furniture, null)); + assertEquals("meal(type=pizza)", env.resolve(layer(1)).getId()); + assertEquals(2, layer(1).getAmount()); + assertNull(layer(2)); + assertTrue(variables.isEmpty()); + } + + @Test + void ovensBakeThePizzaWhileHotThenShowEveryLayerCookedAndBurnt() { + Furniture oven = other(); + PizzaBaking.tick(oven, env.furniture); + assertTrue(variables.isEmpty()); + + slot("pizza_1", dough()); + slot("pizza_2", new ItemStack(Material.STONE)); + env.heated = false; + PizzaBaking.tick(oven, env.furniture); + assertTrue(variables.isEmpty()); + + env.heated = true; + PizzaBaking.tick(oven, env.furniture); + assertEquals(1, PizzaPeel.elapsed(env.furniture)); + assertEquals(PizzaPeel.RAW, PizzaPeel.baked(env.furniture)); + assertTrue(modelRefs.isEmpty()); + variables.put(PizzaPeel.SLICE, "a slice cut before"); + PizzaBaking.tick(oven, env.furniture); + assertEquals(PizzaPeel.COOKED, PizzaPeel.baked(env.furniture)); + assertEquals(List.of("ia.base_cooked"), modelRefs); + // Slices cut after this take the new bake. + assertFalse(variables.containsKey(PizzaPeel.SLICE)); + assertEquals(Material.STONE, layer(2).getType()); + PizzaBaking.tick(oven, env.furniture); + PizzaBaking.tick(oven, env.furniture); + assertEquals(PizzaPeel.BURNT, PizzaPeel.baked(env.furniture)); + assertEquals(List.of("ia.base_cooked", "ia.base_burnt"), modelRefs); + PizzaBaking.tick(oven, env.furniture); + assertEquals(4, PizzaPeel.elapsed(env.furniture)); + verify(InteractibleFurniture.getInstance().getFurnitureManager(), times(4)).markDirty(env.furniture); + } + + @Test + void loadedPizzasAreStackedAgainOnStartAndWhenTheirChunkLoadsAndShrinkWithTheirPeel() { + env.placed.put(UUID.randomUUID(), other()); + slot("pizza_1", dough()); + slot("pizza_2", new ItemStack(Material.STONE)); + slot("pizza_3", null); + slot("pizza_x", cheese()); + slot("pizza_4", paste("Tomato")); + + handler.start(); + assertEquals(1f / 16, lift(2), 1e-6); + // Something that isn't a layer adds no height, and a slot that isn't numbered comes last. + assertEquals(1f / 16, lift(4), 1e-6); + assertEquals(1.2f / 16, lift("pizza_x"), 1e-6); + verify(env.active.get("pizza_3"), never()).applyDisplayData(any()); + + clearInvocations(env.active.get("pizza_1")); + handler.onChunkLoad(new ChunkLoadEvent(env.chunk, false)); + verify(env.active.get("pizza_1"), never()).applyDisplayData(any()); + env.server.getScheduler().performTicks(2); + verify(env.active.get("pizza_1")).applyDisplayData(any()); + + // In an oven's cavity the peel is drawn smaller, and the layers' lift shrinks with it; out again, it grows back. + Furniture oven = other(); + PlacedFurnitureSlot cavity = mock(PlacedFurnitureSlot.class); + when(cavity.getNested()).thenReturn(env.furniture); + Map cavities = new HashMap<>(); + cavities.put("tray", cavity); + cavities.put("empty", mock(PlacedFurnitureSlot.class)); + when(oven.getActiveFurnitureSlots()).thenReturn(cavities); + env.placed.remove(env.furniture.getEntityId()); + env.placed.put(UUID.randomUUID(), oven); + ItemDisplay display = env.display(env.furniture.getEntityId(), new Location(env.world, 0, 64, 0), + new Transformation(new Vector3f(), new Quaternionf(), new Vector3f(0.8f), new Quaternionf())); + env.server.getScheduler().performTicks(10); + assertEquals(0.8f / 16, lift(2), 1e-6); + clearInvocations(env.active.get("pizza_2")); + env.server.getScheduler().performTicks(10); + verify(env.active.get("pizza_2"), never()).applyDisplayData(any()); + display.setTransformation(env.identity()); + env.server.getScheduler().performTicks(10); + assertEquals(1f / 16, lift(2), 1e-6); + } +}