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); + } +}