diff --git a/README.md b/README.md index 152f27b..816afbb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **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`). Its layers also earn it bonuses such as Hearty, Buttered or Well-Rounded (`bonuses` in `sandwiches.yml`). - **Pizzas** — place a pizza peel, put dough on it, spread a paste and add cheese, vegetables, fruit, sausage and spices, then carry the peel into a lit oven to bake. A baked pizza is taken off a slice at a time. Its toppings name it, and its food, quality and freshness come from its layers (`pizzas.yml`). Its toppings also earn it bonuses such as Saucy, Extra Cheesy or Well Seasoned (`bonuses` in `pizzas.yml`). +- **Salads** — right-click a placed bowl with chopped or cooked veg, fruit, cheese or meat, add a dressing or seasoning, and toss it with an empty hand. It stays in the bowl and is eaten from it. Its ingredients name it, its kind (fruit, greek, carrot, mixed) gives it its look, and it earns bonuses such as Dressed or Crisp (`salads.yml`). - **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 98fd73a..90d772f 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -66,6 +66,8 @@ import net.tfminecraft.cooking.pizza.PizzaHandler; import net.tfminecraft.cooking.condiment.CondimentConfig; import net.tfminecraft.cooking.condiment.SapTapListener; +import net.tfminecraft.cooking.salad.SaladConfig; +import net.tfminecraft.cooking.salad.SaladHandler; import net.tfminecraft.cooking.sandwich.SandwichConfig; import net.tfminecraft.cooking.sandwich.SandwichHandler; import net.tfminecraft.cooking.milling.MillingRecipeLoader; @@ -142,6 +144,8 @@ public class Cooking extends JavaPlugin { private final SandwichHandler sandwichHandler = new SandwichHandler(); private final PizzaConfig pizzaConfig = new PizzaConfig(); private final PizzaHandler pizzaHandler = new PizzaHandler(); + private final SaladConfig saladConfig = new SaladConfig(); + private final SaladHandler saladHandler = new SaladHandler(); private final MillingStoneHandler millingStoneHandler = new MillingStoneHandler(); private final TroughHandler troughHandler = new TroughHandler(); private final TroughAging troughAging = new TroughAging(); @@ -268,6 +272,7 @@ public void loadConfigs() { sandwichConfig.load(new File(getDataFolder(), "sandwiches.yml")); pizzaConfig.load(new File(getDataFolder(), "pizzas.yml")); condimentConfig.load(new File(getDataFolder(), "condiments.yml")); + saladConfig.load(new File(getDataFolder(), "salads.yml")); millingRecipeLoader.load(new File(getDataFolder(), "milling-recipes.yml")); stationLoader.load(new File(getDataFolder(), "crafting-stations.yml")); trackLoader.load(new File(getDataFolder(), "tags.yml")); @@ -334,6 +339,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(sandwichHandler, this); getServer().getPluginManager().registerEvents(pizzaHandler, this); getServer().getPluginManager().registerEvents(new SapTapListener(), this); + getServer().getPluginManager().registerEvents(saladHandler, this); getServer().getPluginManager().registerEvents(millingStoneHandler, this); getServer().getPluginManager().registerEvents(troughHandler, this); getServer().getPluginManager().registerEvents(ovenLifecycleHandler, this); @@ -430,6 +436,7 @@ public void createConfigs() { "sandwiches.yml", "pizzas.yml", "condiments.yml", + "salads.yml", "milling-recipes.yml", "cookware.yml", "tags.yml", diff --git a/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java b/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java index ca8395a..9c011ef 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java @@ -25,6 +25,7 @@ import net.tfminecraft.cooking.cache.FurnitureCache; import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.salad.SaladHandler; import net.tfminecraft.cooking.utils.Encoder; import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.HeldStack; @@ -187,6 +188,10 @@ public void addSoup(Player p, Furniture f, FoodItem soup, ItemStack base) { if (f.getType() == null || f.getType().getSlot("food_item") == null) return; PlacedSlot current = f.getActiveSlot("food_item").orElse(null); if (current != null && current.getCurrentItem() != null && !current.getCurrentItem().getType().isAir()) return; + if (SaladHandler.isMaking(f)) { + p.sendMessage("§7There's a salad in this bowl; toss it or take it out first."); + return; + } ItemStack emptyLadle = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle); if (emptyLadle == null || emptyLadle.getType().isAir()) return; Map map = Encoder.decodeSlots(base.getItemMeta().getPersistentDataContainer().get(Keys.SLOT_DATA, PersistentDataType.STRING)); diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java index c01c905..acb7d70 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java @@ -12,6 +12,7 @@ import net.tfminecraft.cooking.cache.FurnitureCache; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.manager.PlateManager; +import net.tfminecraft.cooking.salad.SaladConfig; import net.tfminecraft.cooking.utils.PlayerActionContext; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -56,7 +57,7 @@ public void onInteract(FurnitureInteractEvent event) { } FoodItem food = FoodItem.fromItem(soupStack); - if (food == null || !food.getCategory().equalsIgnoreCase("soup")) { + if (food == null || !(food.getCategory().equalsIgnoreCase("soup") || SaladConfig.isSalad(food))) { return; } diff --git a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java index 66c1434..1ea2d0a 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java +++ b/src/main/java/net/tfminecraft/cooking/quality/CompositionContext.java @@ -11,6 +11,7 @@ public enum CompositionContext { SAUSAGE_MAKER(true), SANDWICH(true), PIZZA(true), + SALAD(true), MILLING(false), BAKING(false); diff --git a/src/main/java/net/tfminecraft/cooking/salad/SaladConfig.java b/src/main/java/net/tfminecraft/cooking/salad/SaladConfig.java new file mode 100644 index 0000000..f64611b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/salad/SaladConfig.java @@ -0,0 +1,151 @@ +package net.tfminecraft.cooking.salad; + +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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.DishBonus; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +/** salads.yml: the bowls salads are tossed in, what they take, the kinds they make and the bonuses they earn. */ +public final class SaladConfig { + private static final Pattern TYPE = Pattern.compile("type=([^;)]+)"); + + private static Set furniture = Set.of(); + private static int maxIngredients = 5; + private static int minIngredients = 2; + private static int maxExtras = 3; + private static String output = "meal(type=salad;origin=Mixed)"; + private static String outputType = "salad"; + private static List ingredients = List.of(); + private static List extras = List.of(); + private static Set once = Set.of(); + private static Set returnsBottle = Set.of(); + private static List kinds = List.of(); + private static List bonuses = List.of(); + + public void load(File file) { + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + Set bowls = new HashSet<>(); + for (String id : config.getStringList("furniture")) bowls.add(id.toLowerCase(Locale.ROOT)); + furniture = bowls; + maxIngredients = Math.max(1, config.getInt("max-ingredients", 5)); + minIngredients = Math.max(1, Math.min(maxIngredients, config.getInt("min-ingredients", 2))); + maxExtras = Math.max(0, config.getInt("max-extras", 3)); + output = config.getString("output", "meal(type=salad;origin=Mixed)"); + Matcher type = TYPE.matcher(output); + outputType = type.find() ? type.group(1).trim().toLowerCase(Locale.ROOT) : "salad"; + ingredients = layers(config.getConfigurationSection("ingredients"), null, null); + Set single = new HashSet<>(); + Set bottles = new HashSet<>(); + extras = layers(config.getConfigurationSection("extras"), single, bottles); + once = single; + returnsBottle = bottles; + List loaded = new ArrayList<>(); + ConfigurationSection section = config.getConfigurationSection("kinds"); + if (section != null) { + for (String origin : section.getKeys(false)) { + ConfigurationSection kind = section.getConfigurationSection(origin); + if (kind != null) loaded.add(new SaladKind(origin, kind)); + } + } + kinds = loaded; + bonuses = DishBonus.load(config.getConfigurationSection("bonuses")); + } + + private static List layers(ConfigurationSection section, Set single, Set bottles) { + List loaded = new ArrayList<>(); + if (section == null) return loaded; + for (String id : section.getKeys(false)) { + ConfigurationSection layer = section.getConfigurationSection(id); + if (layer == null) continue; + loaded.add(new SandwichLayer(id, layer)); + if (single != null && layer.getBoolean("once", false)) single.add(id.toLowerCase(Locale.ROOT)); + if (bottles != null && layer.getBoolean("returns-bottle", false)) bottles.add(id.toLowerCase(Locale.ROOT)); + } + return loaded; + } + + public static boolean isSaladFurniture(Furniture f) { + return f != null && f.getType() != null && furniture.contains(f.getType().getId().toLowerCase(Locale.ROOT)); + } + + /** The ingredient layer this food makes (shown in the bowl), or null. */ + public static SandwichLayer ingredientFor(FoodItem food) { + for (SandwichLayer layer : ingredients) { + if (layer.matches(food)) return layer; + } + return null; + } + + /** The extra (dressing, seasoning) this food makes, or null. Extras don't show and don't name the salad. */ + public static SandwichLayer extraFor(FoodItem food) { + for (SandwichLayer layer : extras) { + if (layer.matches(food)) return layer; + } + return null; + } + + /** The ingredient or extra this food makes, or null. */ + public static SandwichLayer layerFor(FoodItem food) { + SandwichLayer layer = ingredientFor(food); + return layer != null ? layer : extraFor(food); + } + + public static boolean isExtra(SandwichLayer layer) { + return extras.contains(layer); + } + + /** Only one food of this extra goes in a salad (one dressing). */ + public static boolean isOnce(SandwichLayer layer) { + return once.contains(layer.getId().toLowerCase(Locale.ROOT)); + } + + /** This extra comes in a bottle the player keeps (olive oil, vinegar). */ + public static boolean returnsBottle(SandwichLayer layer) { + return returnsBottle.contains(layer.getId().toLowerCase(Locale.ROOT)); + } + + /** A finished salad: the output's type. */ + public static boolean isSalad(FoodItem food) { + return food != null && food.getId() != null && food.getId().equalsIgnoreCase(outputType); + } + + /** The first kind these ingredients make, or null when none takes them. */ + public static SaladKind kindFor(List foods, List made) { + for (SaladKind kind : kinds) { + if (kind.matches(foods, made)) return kind; + } + return null; + } + + public static int maxIngredients() { + return maxIngredients; + } + + public static int minIngredients() { + return minIngredients; + } + + public static int maxExtras() { + return maxExtras; + } + + public static String output() { + return output; + } + + public static List bonuses() { + return bonuses; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/salad/SaladHandler.java b/src/main/java/net/tfminecraft/cooking/salad/SaladHandler.java new file mode 100644 index 0000000..eeb8172 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/salad/SaladHandler.java @@ -0,0 +1,254 @@ +package net.tfminecraft.cooking.salad; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.cooking.PotReference; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.manager.BowlIngredientLayout; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.HeldStack; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; + +/** + * Tosses salads in a placed bowl (salads.yml). Right-clicking an empty bowl with an ingredient lays it in the bowl + * (input_1, input_2, ...); dressings and seasonings go in unseen. Sneaking with an empty hand takes the last thing + * back. An empty-hand right-click tosses the salad, which then stays in the bowl (food_item) until it's eaten there + * (BowlEatHandler); it can't be taken out. + */ +public final class SaladHandler implements Listener { + static final String INPUT_PREFIX = "input_"; + static final String SALAD_SLOT = "food_item"; + static final String EXTRAS = "salad_extras"; + private static final String SEPARATOR = ","; + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInteract(FurnitureInteractEvent event) { + Furniture f = event.getFurniture(); + if (!SaladConfig.isSaladFurniture(f)) return; + // A soup or a tossed salad is eaten from the bowl; BowlEatHandler handles that. + if (has(f, SALAD_SLOT)) return; + Player p = event.getPlayer(); + ItemStack hand = p.getInventory().getItemInMainHand(); + boolean emptyHand = hand == null || hand.getType().isAir(); + List shown = ingredients(f); + List extras = extras(f); + + if (emptyHand) { + if (shown.isEmpty() && extras.isEmpty()) return; + event.setCancelled(true); + if (p.isSneaking()) takeLast(p, f, shown, extras); + else toss(p, f, shown, extras); + return; + } + FoodItem food = FoodItem.fromItem(hand); + SandwichLayer ingredient = SaladConfig.ingredientFor(food); + if (ingredient != null) { + event.setCancelled(true); + String slot = freeSlot(f); + if (slot == null || shown.size() >= SaladConfig.maxIngredients()) { + p.sendMessage("§7The bowl is full; toss the salad with an empty hand."); + return; + } + addIngredient(p, f, slot, hand); + return; + } + SandwichLayer extra = SaladConfig.extraFor(food); + if (extra == null) return; + event.setCancelled(true); + String refused = refuseExtra(extras, food, extra); + if (refused != null) { + p.sendMessage("§7" + refused); + return; + } + addExtra(p, f, hand, extras, extra); + } + + /** Why this extra can't go in, or null when it can. */ + static String refuseExtra(List extras, FoodItem food, SandwichLayer extra) { + if (extras.size() >= SaladConfig.maxExtras()) return "There's enough dressing and seasoning in it already."; + String origin = origin(food); + for (ItemStack stack : extras) { + FoodItem in = FoodItem.fromItem(stack); + if (in == null) continue; + if (SaladConfig.isOnce(extra) && extra == SaladConfig.extraFor(in)) return "It already has a dressing."; + if (origin.equals(origin(in))) return "That's already in it."; + } + return null; + } + + /** What's left in a broken bowl that hasn't been tossed drops; a tossed salad goes with the bowl, like soup. */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBreak(FurnitureBreakEvent event) { + Furniture f = event.getFurniture(); + if (!SaladConfig.isSaladFurniture(f) || has(f, SALAD_SLOT)) return; + List drops = new ArrayList<>(); + for (PlacedSlot slot : ingredients(f)) { + drops.add(asHeld(slot.getCurrentItem())); + clear(f, slot); + } + drops.addAll(extras(f)); + f.getVariables().remove(EXTRAS); + for (ItemStack drop : drops) f.getLoc().getWorld().dropItem(f.getLoc(), drop); + } + + private void addIngredient(Player p, Furniture f, String slotId, ItemStack hand) { + ItemStack one = hand.clone(); + one.setAmount(1); + PlacedSlot slot = f.getOrCreatePlacedSlot(slotId); + slot.forceModel(one); + DisplayData spread = BowlIngredientLayout.offsetFor(slotId); + if (spread != null) slot.applyDisplayData(spread); + hand.setAmount(hand.getAmount() - 1); + p.getInventory().setItemInMainHand(hand); + done(p, f, Sound.ENTITY_ITEM_FRAME_ADD_ITEM); + } + + private void addExtra(Player p, Furniture f, ItemStack hand, List extras, SandwichLayer extra) { + ItemStack one = hand.clone(); + one.setAmount(1); + extras.add(one); + setExtras(f, extras); + if (SaladConfig.returnsBottle(extra)) { + HeldStack.useOneKeepStack(p, new ItemStack(Material.GLASS_BOTTLE)); + } else { + hand.setAmount(hand.getAmount() - 1); + p.getInventory().setItemInMainHand(hand); + } + done(p, f, Sound.ITEM_BOTTLE_EMPTY); + } + + /** Takes back what went in last: the last ingredient, or with none, the last extra. */ + private void takeLast(Player p, Furniture f, List shown, List extras) { + ItemStack back; + if (!shown.isEmpty()) { + PlacedSlot last = shown.get(shown.size() - 1); + back = asHeld(last.getCurrentItem()); + clear(f, last); + } else { + back = extras.remove(extras.size() - 1); + setExtras(f, extras); + } + give(p, back); + done(p, f, Sound.ENTITY_ITEM_FRAME_REMOVE_ITEM); + } + + private void toss(Player p, Furniture f, List shown, List extras) { + if (shown.size() < SaladConfig.minIngredients()) { + p.sendMessage("§7Add at least " + SaladConfig.minIngredients() + " ingredients before you toss it."); + return; + } + List items = new ArrayList<>(); + for (PlacedSlot slot : shown) items.add(slot.getCurrentItem()); + ItemStack salad = SaladItems.build(p, items, extras); + if (salad == null) { + p.sendMessage("§cThis salad can't be made right now."); + return; + } + // The salad itself stays out of sight: the ingredients in the bowl show it. + PlacedSlot slot = f.getOrCreatePlacedSlot(SALAD_SLOT); + slot.forceModel(salad); + DisplayData hidden = new DisplayData(); + hidden.setxScale(0.001f); + hidden.setyScale(0.001f); + hidden.setzScale(0.001f); + slot.applyDisplayData(hidden); + f.getVariables().remove(EXTRAS); + done(p, f, Sound.BLOCK_GRASS_STEP); + Bukkit.getPluginManager().callEvent(new DishCookedEvent(p, salad, "salad")); + } + + private static void done(Player p, Furniture f, Sound sound) { + InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); + f.getLoc().getWorld().playSound(f.getLoc(), sound, 1f, 1f); + p.swingMainHand(); + } + + /** True while a bowl holds salad ingredients or extras that haven't been tossed yet. */ + public static boolean isMaking(Furniture f) { + return SaladConfig.isSaladFurniture(f) && !has(f, SALAD_SLOT) && (!ingredients(f).isEmpty() || !extras(f).isEmpty()); + } + + private static boolean has(Furniture f, String slotId) { + PlacedSlot slot = f.getActiveSlot(slotId).orElse(null); + return slot != null && slot.getCurrentItem() != null && !slot.getCurrentItem().getType().isAir(); + } + + /** The bowl's ingredient slots that hold something, in order. */ + static List ingredients(Furniture f) { + List shown = new ArrayList<>(); + for (int i = 1; f.getType().getSlot(INPUT_PREFIX + i) != null; i++) { + if (has(f, INPUT_PREFIX + i)) shown.add(f.getActiveSlot(INPUT_PREFIX + i).get()); + } + return shown; + } + + private static String freeSlot(Furniture f) { + for (int i = 1; f.getType().getSlot(INPUT_PREFIX + i) != null; i++) { + if (!has(f, INPUT_PREFIX + i)) return INPUT_PREFIX + i; + } + return null; + } + + /** The dressings and seasonings in the bowl, kept in its variables so they survive restarts. */ + static List extras(Furniture f) { + List extras = new ArrayList<>(); + if (!(f.getVariables().get(EXTRAS) instanceof String encoded)) return extras; + for (String part : encoded.split(SEPARATOR)) { + ItemStack stack = PotReference.decodeStack(part); + if (stack != null) extras.add(stack); + } + return extras; + } + + private static void setExtras(Furniture f, List extras) { + if (extras.isEmpty()) { + f.getVariables().remove(EXTRAS); + return; + } + List parts = new ArrayList<>(); + for (ItemStack stack : extras) parts.add(PotReference.encodeStack(stack)); + f.getVariables().put(EXTRAS, String.join(SEPARATOR, parts)); + } + + private static String origin(FoodItem food) { + return food.getOrigin() == null ? "" : food.getOrigin().toLowerCase(Locale.ROOT); + } + + 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); + } + + /** An ingredient 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/salad/SaladItems.java b/src/main/java/net/tfminecraft/cooking/salad/SaladItems.java new file mode 100644 index 0000000..b2f3f45 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/salad/SaladItems.java @@ -0,0 +1,68 @@ +package net.tfminecraft.cooking.salad; + +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.quality.CompositionApplier; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionFreshnessApplier; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.sandwich.DishBonus; +import net.tfminecraft.cooking.sandwich.SandwichItems; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; + +/** Tosses what's in a bowl into one salad. */ +public final class SaladItems { + + private SaladItems() {} + + /** + * The salad these ingredients and extras make: its food is what they all hold (like a sandwich's), its quality, + * freshness and variety come from all of them, its ingredients name it (Tomato, Olive and Cheese Salad) and its + * kind (salads.yml kinds) gives it its origin and look. Null when salads.yml's output makes no item. + */ + public static ItemStack build(Player player, List ingredients, List extras) { + List shown = foods(ingredients); + List all = new ArrayList<>(shown); + all.addAll(foods(extras)); + FoodParser.Result parsed = FoodParser.parse(SaladConfig.output()); + if (shown.isEmpty() || parsed == null || parsed.template == null) return null; + FoodItem salad = parsed.template; + CompositionResult composed = CompositionQualityResolver.compose(player, all, CompositionContext.SALAD); + CompositionApplier.apply(salad, composed); + CompositionFreshnessApplier.keepOwnAgeing(salad); + SandwichItems.applyTotals(salad, all); + List named = new ArrayList<>(); + List made = new ArrayList<>(); + for (FoodItem food : shown) { + SandwichLayer layer = SaladConfig.ingredientFor(food); + if (layer == null) continue; + named.add(food); + made.add(layer); + salad.addIngredient(layer.fillerName(food)); + } + SaladKind kind = SaladConfig.kindFor(named, made); + if (kind != null) salad.setOrigin(kind.getOrigin()); + DishBonus.apply(salad, all, SaladConfig::layerFor, layer -> !SaladConfig.isExtra(layer), SaladConfig.bonuses()); + return ItemBuilder.buildComposedWithQuality(salad, composed.getFinalQuality()); + } + + private static List foods(List stacks) { + List foods = new ArrayList<>(); + for (ItemStack stack : stacks) { + FoodItem food = FoodItem.fromItem(stack); + if (food == null) continue; + // An ingredient ages in the bowl as it would anywhere else. + food.updateAge(); + foods.add(food); + } + return foods; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java b/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java new file mode 100644 index 0000000..c12430b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/salad/SaladKind.java @@ -0,0 +1,71 @@ +package net.tfminecraft.cooking.salad; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.configuration.ConfigurationSection; + +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.sandwich.SandwichLayer; + +/** + * One kind of salad from salads.yml kinds (Fruit, Greek, Carrot, Mixed): what its ingredients must be. A salad of this + * kind takes the kind's name as its origin, so types.yml overrides give it its own look. + * + *
+ * Greek:
+ *   layers: [cheese]            # every one of these ingredient layers
+ *   only: [fruit]               # nothing but these layers
+ *   origins: [Olive]            # every one of these origins
+ *   any-origin: [Tomato]        # at least one of these
+ *   mostly: Carrot              # at least half the ingredients are this
+ * 
+ * A kind with no rules takes any salad, so the last one catches the rest. + */ +public final class SaladKind { + private final String origin; + private final List layers = new ArrayList<>(); + private final List only = new ArrayList<>(); + private final List origins = new ArrayList<>(); + private final List anyOrigin = new ArrayList<>(); + private final String mostly; + + public SaladKind(String origin, ConfigurationSection config) { + this.origin = origin; + lower(config.getStringList("layers"), layers); + lower(config.getStringList("only"), only); + lower(config.getStringList("origins"), origins); + lower(config.getStringList("any-origin"), anyOrigin); + String most = config.getString("mostly"); + this.mostly = most == null ? null : most.toLowerCase(Locale.ROOT); + } + + private static void lower(List from, List to) { + for (String s : from) to.add(s.toLowerCase(Locale.ROOT)); + } + + /** The origin a salad of this kind takes (Greek). */ + public String getOrigin() { + return origin; + } + + /** Whether these ingredients, with the layers they make, are this kind of salad. */ + public boolean matches(List foods, List made) { + Map byOrigin = new HashMap<>(); + List layerIds = new ArrayList<>(); + for (int i = 0; i < foods.size(); i++) { + String id = made.get(i).getId().toLowerCase(Locale.ROOT); + if (!only.isEmpty() && !only.contains(id)) return false; + layerIds.add(id); + String from = foods.get(i).getOrigin(); + byOrigin.merge(from == null ? "" : from.toLowerCase(Locale.ROOT), 1, Integer::sum); + } + if (!layerIds.containsAll(layers)) return false; + if (!byOrigin.keySet().containsAll(origins)) return false; + if (!anyOrigin.isEmpty() && anyOrigin.stream().noneMatch(byOrigin::containsKey)) return false; + return mostly == null || byOrigin.getOrDefault(mostly, 0) * 2 >= foods.size(); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java index 9648656..d5ab7ef 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/DishBonus.java @@ -36,10 +36,12 @@ * min: 2 # each of the layers above at least this often * addons: rounded # a chopped garnish and a spice (aromatic: two of one kind) * valuable: true # a food types.yml marks valuable (tomato, onion...) + * fresh: true # every layer still at its first freshness step * * A bonus with no count takes its track's first step, or the one named in step. */ public final class DishBonus { + private static final String FRESHNESS = "freshness"; private final String track; private final String step; private final List layers = new ArrayList<>(); @@ -47,6 +49,7 @@ public final class DishBonus { private final int min; private final String addons; private final boolean valuable; + private final boolean fresh; private final String count; private final TreeMap steps = new TreeMap<>(); @@ -59,6 +62,7 @@ public DishBonus(ConfigurationSection config) { String profile = config.getString("addons"); this.addons = profile == null ? null : profile.toUpperCase(Locale.ROOT); this.valuable = config.getBoolean("valuable", false); + this.fresh = config.getBoolean("fresh", false); this.count = config.getString("count"); ConfigurationSection counted = config.getConfigurationSection("steps"); if (counted != null) { @@ -94,6 +98,7 @@ public static void apply(FoodItem dish, List foods, Function seasonings = new HashSet<>(); Map all = new HashMap<>(); int fillings = 0; + boolean allFresh = true; for (FoodItem food : foods) { SandwichLayer layer = layerFor.apply(food); if (layer == null) continue; @@ -103,24 +108,27 @@ public static void apply(FoodItem dish, List foods, Function counts, int counted, String profile, boolean hasValuable) { + String earned(Map counts, int counted, String profile, boolean hasValuable, boolean allFresh) { for (String layer : layers) { if (counts.getOrDefault(layer, 0) < min) return null; } if (!anyOf.isEmpty() && anyOf.stream().noneMatch(counts::containsKey)) return null; if (addons != null && !addons.equals(profile)) return null; if (valuable && !hasValuable) return null; + if (fresh && !allFresh) return null; if (count == null) return step == null ? "" : step; Map.Entry reached = steps.floorEntry(counted); return reached == null ? null : reached.getValue(); diff --git a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java index 91a1109..e321d69 100644 --- a/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java +++ b/src/main/java/net/tfminecraft/cooking/sandwich/SandwichItems.java @@ -61,7 +61,7 @@ public static ItemStack build(Player player, List stacks) { * Food is what the layers hold between them. Nutrition is their average, weighted by how much each one feeds, as * eating them one after another would give. */ - static void applyTotals(FoodItem sandwich, List layers) { + public static void applyTotals(FoodItem sandwich, List layers) { double food = 0; double nutrition = 0; for (FoodItem layer : layers) { diff --git a/src/main/resources/composition.yml b/src/main/resources/composition.yml index 6d0fdd5..b08153e 100644 --- a/src/main/resources/composition.yml +++ b/src/main/resources/composition.yml @@ -38,6 +38,13 @@ context-overrides: - dairy SAUSAGE_MAKER: extra: [] + SALAD: + extra: + - salt + - pepper + - spice + - oil + - vinegar modifiers: enabled: true diff --git a/src/main/resources/salads.yml b/src/main/resources/salads.yml new file mode 100644 index 0000000..846bb85 --- /dev/null +++ b/src/main/resources/salads.yml @@ -0,0 +1,113 @@ +# Salads, tossed in a placed bowl: +# 1. Right-click an empty bowl with ingredients to lay them in it (input_1, input_2, ... of the bowl furniture). +# 2. Right-click with a dressing or seasoning to add it (it doesn't show). Sneak with an empty hand to take back +# what went in last. +# 3. Right-click with an empty hand to toss the salad. It stays in the bowl and is eaten from it with another +# empty-hand right-click; it can't be taken out. +# The salad's food is what everything in it holds between them, its nutrition their average (weighted by food), and its +# quality, freshness and variety come from all of them (composition.yml context SALAD). Its ingredients name it: +# "Tomato, Olive and Cheese Salad" (naming.yml fillers max). Cut veg and meat keep their cooking, so raw cut veg +# keeps its raw penalty and fried meat its bonus. + +# Furniture ids salads are tossed in. +furniture: + - bowl + +# Ingredients in a salad, at least and at most (no more than the bowl's input slots). +min-ingredients: 2 +max-ingredients: 5 + +# Dressings and seasonings in a salad, at most. Each origin goes in once. +max-extras: 3 + +# The finished salad (types.yml). Its food and nutrition are replaced by what's in it. +output: meal(type=salad;origin=Mixed) + +# What shows in the bowl and names the salad, matched by types.yml type; the first that takes a food wins. +# name the ingredient's part of the salad's name; {origin} is the food's origin (Tomato) +# names a name per origin instead (goat: Goat Cheese) +ingredients: + vegetable: + types: + - vegetable_chopped + - vegetable_chopped_2 + - garnish_chopped + - vegetable_cut + - vegetable_cut_2 + name: "{origin}" + fruit: + types: + - fruit_1 + - fruit_2 + name: "{origin}" + names: + melon slice: Melon + cheese: + types: + - cheese + name: Cheese + names: + goat: Goat Cheese + meat: + types: + - meat_red_meat + - meat_poultry + - meat_poultry_leg + - meat_pork + - sausage + name: "{origin}" + +# What goes in unseen and doesn't name the salad. +# once only one food of this extra per salad (one dressing) +# returns-bottle the player keeps the empty glass bottle +extras: + dressing: + types: + - cooking_oil + - vinegar + once: true + returns-bottle: true + seasoning: + types: + - seasoning_1 + - spice_1 + +# The kind of salad its ingredients make; the first that matches wins. The salad takes the kind as its origin, so +# types.yml overrides give each kind its own look. +# layers every one of these ingredients (ids above) +# only nothing but these ingredients +# origins every one of these origins +# any-origin at least one of these origins +# mostly at least half the ingredients are this origin +kinds: + Fruit: + only: [fruit] + Greek: + layers: [cheese] + origins: [Olive] + any-origin: [Tomato, Cucumber] + Carrot: + mostly: Carrot + Mixed: {} + +# Tags a salad earns from what's in it, written like sandwiches.yml's bonuses. count: fillings counts the ingredients, +# count: seasonings the different salts and peppers; fresh: true needs everything in it still Fresh. +bonuses: + size: + track: dish_size + count: fillings + steps: + 3: hearty + 5: loaded + dressed: + track: dressed + layers: [dressing] + seasoning: + track: seasoning + count: seasonings + steps: + 1: seasoned + 2: well_seasoned + crisp: + track: crisp + fresh: true diff --git a/src/main/resources/tags.yml b/src/main/resources/tags.yml index 9fe4bb3..a71e3bf 100644 --- a/src/main/resources/tags.yml +++ b/src/main/resources/tags.yml @@ -266,3 +266,17 @@ sweet_and_savoury: value: 0 food-mult: 1.10 nutrition-mult: 1.10 + +# Salad bonuses (salads.yml bonuses). +dressed: + dressed: + name: "#b5c46aDressed" + value: 0 + food-mult: 1.15 + nutrition-mult: 1.15 + +crisp: + crisp: + name: "#8fd16aCrisp" + value: 0 + nutrition-mult: 1.10 diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index 19476b9..867f2fc 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -164,10 +164,10 @@ class CommandManagerCoverageTest { FoodItem roast = new FoodItem("roast", config); staticMock(FoodLoader.class).when(() -> FoodLoader.getByString("roast")).thenReturn(roast); List contexts = List.of("CUTTING_BOARD", "MIXING_BOWL", "SAUCE_SCOOP", "SOUP_SCOOP", "CARVE", - "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "SANDWICH", "PIZZA", "MILLING", "BAKING"); + "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "SANDWICH", "PIZZA", "SALAD", "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")); + assertEquals(List.of("SAUCE_SCOOP", "SAUSAGE_MAKER", "SANDWICH", "SALAD"), tabs("qualitytest", "compose2", "food(type=roast)|food(type=roast)", "sA")); assertEquals(contexts, tabs("qualitytest", "compose", "food(type=roast;origin=Sunday", "roast)", "")); assertEquals(contexts, tabs("qualitytest", "compose2", "food(type=roast)", "|", "food(type=roast)", "")); assertTrue(tabs("qualitytest", "compose", "food(type=roast)").isEmpty()); diff --git a/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java index d1b0c98..e903392 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java @@ -261,6 +261,23 @@ void soupCannotOverwriteAnExistingServingOrDisappearIntoAnUnusableBowl(String fa else assertTrue(bowl.active.isEmpty()); } + @Test + void soupWaitsForASaladBeingMadeInTheBowl() throws Exception { + java.io.File salads = java.nio.file.Files.createTempFile("salads", ".yml").toFile(); + java.nio.file.Files.writeString(salads.toPath(), "furniture: [bowl]\n"); + new net.tfminecraft.cooking.salad.SaladConfig().load(salads); + try { + bowl.place("input_1", new ItemStack(Material.CARROT)); + ItemStack full = env.hold(soup()); plates.interact(bowl.interact()); + assertEquals(full, env.player.getInventory().getItemInMainHand()); + assertFalse(bowl.active.containsKey("food_item")); + verify(env.player).sendMessage("§7There's a salad in this bowl; toss it or take it out first."); + } finally { + new net.tfminecraft.cooking.salad.SaladConfig().load(new java.io.File(salads.getParentFile(), "missing-salads.yml")); + salads.delete(); + } + } + @Test void bowlIngredientPositionsAreDistinctAndUnsupportedSlotNamesHaveNoOffset() { for (int index = 1; index <= 5; index++) { diff --git a/src/test/java/net/tfminecraft/cooking/salad/SaladCoverageTest.java b/src/test/java/net/tfminecraft/cooking/salad/SaladCoverageTest.java new file mode 100644 index 0000000..f2dbeb0 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/salad/SaladCoverageTest.java @@ -0,0 +1,371 @@ +package net.tfminecraft.cooking.salad; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.cooking.PotReference; +import net.tfminecraft.cooking.crafting.CraftingStationCoverageTest.Environment; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.sandwich.SandwichLayer; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class SaladCoverageTest { + static final String CONFIG = """ + furniture: [BOARD] + min-ingredients: 2 + max-ingredients: 3 + max-extras: 2 + output: meal(type=salad;origin=Mixed) + ingredients: + vegetable: + types: [vegetable_chopped] + name: "{origin}" + fruit: + types: [fruit_2] + cheese: + types: [cheese] + name: Cheese + names: + goat: Goat Cheese + plain: 3 + extras: + dressing: + types: [vinegar] + once: true + returns-bottle: true + seasoning: + types: [seasoning_1, spice_1] + plain: 3 + kinds: + Fruit: + only: [fruit] + Greek: + layers: [cheese] + origins: [Olive] + any-origin: [Tomato, Cucumber] + Carrot: + mostly: Carrot + plain: 3 + bonuses: + dressed: + track: dressed + layers: [dressing] + """; + + Environment env; + MockedStatic adder; + final List given = new ArrayList<>(); + final Map variables = new HashMap<>(); + final Map encoded = new HashMap<>(); + final List messages = new ArrayList<>(); + FoodItem built; + final SaladHandler handler = new SaladHandler(); + @TempDir Path directory; + + @BeforeEach void open() throws Exception { + env = new Environment(); + env.define("food_item"); + when(env.furniture.getVariables()).thenReturn(variables); + env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(inv -> { + built = inv.getArgument(0); + return env.stack(built, Material.BOWL, 1); + }); + adder = env.scoped(mockStatic(InventoryAdder.class)); + adder.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> { + given.add(inv.getArgument(1)); + return null; + }); + MockedStatic codec = env.scoped(mockStatic(PotReference.class)); + codec.when(() -> PotReference.encodeStack(any())).thenAnswer(inv -> { + String key = "e" + encoded.size(); + encoded.put(key, inv.getArgument(0)); + return key; + }); + codec.when(() -> PotReference.decodeStack(anyString())).thenAnswer(inv -> encoded.get((String) inv.getArgument(0))); + doAnswer(inv -> { messages.add(inv.getArgument(0)); return null; }).when(env.player).sendMessage(anyString()); + load(CONFIG); + } + + @AfterEach void close() { + new SaladConfig().load(directory.resolve("missing.yml").toFile()); + env.close(); + } + + void load(String yaml) throws Exception { + Path file = directory.resolve("salads.yml"); + Files.writeString(file, yaml); + new SaladConfig().load(file.toFile()); + } + + FoodItem food(String type, String category, String origin) { + FoodItem food = env.food(type, origin); + food.setCategory(category); + return food; + } + + ItemStack veg(String origin) { return env.stack(food("vegetable_chopped", "garnish", origin), Material.CARROT, 4); } + ItemStack fruit(String origin) { return env.stack(food("fruit_2", "fruit", origin), Material.APPLE, 4); } + ItemStack cheese(String origin) { return env.stack(food("cheese", "dairy", origin), Material.YELLOW_WOOL, 2); } + ItemStack vinegar(String origin) { return env.stack(food("vinegar", "vinegar", origin), Material.POTION, 2); } + ItemStack salt() { return env.stack(food("seasoning_1", "salt", "Salt"), Material.SUGAR, 1); } + + FurnitureInteractEvent click(ItemStack held) { + env.player.getInventory().setItemInMainHand(held); + FurnitureInteractEvent event = env.interact("input_1"); + handler.onInteract(event); + return event; + } + + ItemStack in(String slot) { + PlacedSlot placed = env.active.get(slot); + return placed == null ? null : placed.getCurrentItem(); + } + + @Test + void configReadsBowlsIngredientsExtrasAndKindsAndFallsBackToDefaults() throws Exception { + assertTrue(SaladConfig.isSaladFurniture(env.furniture)); + assertFalse(SaladConfig.isSaladFurniture(null)); + when(env.furniture.getType()).thenReturn(null); + assertFalse(SaladConfig.isSaladFurniture(env.furniture)); + when(env.furniture.getType()).thenReturn(env.type); + assertEquals(3, SaladConfig.maxIngredients()); + assertEquals(2, SaladConfig.minIngredients()); + assertEquals(2, SaladConfig.maxExtras()); + assertEquals(1, SaladConfig.bonuses().size()); + + FoodItem tomato = food("vegetable_chopped", "garnish", "Tomato"); + FoodItem vinegar = food("vinegar", "vinegar", "Wine"); + SandwichLayer veg = SaladConfig.ingredientFor(tomato); + SandwichLayer dressing = SaladConfig.extraFor(vinegar); + assertEquals("vegetable", veg.getId()); + assertNull(SaladConfig.extraFor(tomato)); + assertNull(SaladConfig.ingredientFor(vinegar)); + assertSame(veg, SaladConfig.layerFor(tomato)); + assertSame(dressing, SaladConfig.layerFor(vinegar)); + assertNull(SaladConfig.layerFor(food("stone", "rock", "Stone"))); + assertTrue(SaladConfig.isExtra(dressing)); + assertFalse(SaladConfig.isExtra(veg)); + assertTrue(SaladConfig.isOnce(dressing)); + assertTrue(SaladConfig.returnsBottle(dressing)); + SandwichLayer seasoning = SaladConfig.extraFor(env.resolve(salt())); + assertFalse(SaladConfig.isOnce(seasoning)); + assertFalse(SaladConfig.returnsBottle(seasoning)); + + FoodItem salad = food("salad", "meal", "Mixed"); + assertTrue(SaladConfig.isSalad(salad)); + assertFalse(SaladConfig.isSalad(tomato)); + assertFalse(SaladConfig.isSalad(null)); + + new SaladConfig().load(directory.resolve("missing.yml").toFile()); + assertEquals("meal(type=salad;origin=Mixed)", SaladConfig.output()); + assertEquals(5, SaladConfig.maxIngredients()); + assertNull(SaladConfig.ingredientFor(tomato)); + assertNull(SaladConfig.kindFor(List.of(), List.of())); + load("output: plain\nmax-ingredients: 0\nmin-ingredients: 9\n"); + assertEquals(1, SaladConfig.maxIngredients()); + assertEquals(1, SaladConfig.minIngredients()); + assertTrue(SaladConfig.isSalad(salad)); + } + + @Test + void kindsAreFruitGreekCarrotOrNoneByWhatIsInTheBowl() { + SandwichLayer veg = SaladConfig.ingredientFor(food("vegetable_chopped", "garnish", "Tomato")); + SandwichLayer fruit = SaladConfig.ingredientFor(food("fruit_2", "fruit", "Plum")); + SandwichLayer cheese = SaladConfig.ingredientFor(food("cheese", "dairy", "Cow")); + FoodItem plum = food("fruit_2", "fruit", "Plum"); + FoodItem tomato = food("vegetable_chopped", "garnish", "Tomato"); + FoodItem olive = food("vegetable_chopped", "garnish", "Olive"); + FoodItem carrot = food("vegetable_chopped", "garnish", "Carrot"); + FoodItem none = food("vegetable_chopped", "garnish", null); + FoodItem feta = food("cheese", "dairy", "Goat"); + + assertEquals("Fruit", SaladConfig.kindFor(List.of(plum, plum), List.of(fruit, fruit)).getOrigin()); + assertEquals("Greek", SaladConfig.kindFor(List.of(feta, olive, tomato), List.of(cheese, veg, veg)).getOrigin()); + assertNull(SaladConfig.kindFor(List.of(feta, olive), List.of(cheese, veg)), "Greek needs tomato or cucumber"); + assertNull(SaladConfig.kindFor(List.of(olive, tomato), List.of(veg, veg)), "Greek needs cheese"); + assertNull(SaladConfig.kindFor(List.of(feta, tomato), List.of(cheese, veg)), "Greek needs olive"); + assertEquals("Carrot", SaladConfig.kindFor(List.of(carrot, none), List.of(veg, veg)).getOrigin()); + assertNull(SaladConfig.kindFor(List.of(carrot, none, tomato), List.of(veg, veg, veg))); + } + + @Test + void ingredientsGoInTheBowlUntilItIsFull() throws Exception { + assertFalse(click(null).isCancelled(), "An empty bowl and an empty hand do nothing"); + assertFalse(click(new ItemStack(Material.STONE)).isCancelled()); + + ItemStack hand = veg("Tomato"); + FurnitureInteractEvent event = click(hand); + assertTrue(event.isCancelled()); + assertEquals("Tomato", env.resolve(in("input_1")).getOrigin()); + assertEquals(1, in("input_1").getAmount()); + assertEquals(3, env.player.getInventory().getItemInMainHand().getAmount()); + verify(env.active.get("input_1")).applyDisplayData(any()); + assertTrue(SaladHandler.isMaking(env.furniture)); + + click(veg("Onion")); + click(fruit("Plum")); + click(veg("Corn")); + assertNull(in("input_4")); + assertEquals("§7The bowl is full; toss the salad with an empty hand.", messages.get(0)); + + load(CONFIG.replace("max-ingredients: 3", "max-ingredients: 9")); + click(veg("Corn")); + click(veg("Lettuce")); + click(veg("Garlic")); + assertEquals(2, messages.size(), "No free slot left in the bowl"); + } + + @Test + void dressingsAndSeasoningsGoInUnseenOnceEach() throws Exception { + FurnitureInteractEvent event = click(vinegar("Wine")); + assertTrue(event.isCancelled()); + assertEquals(Material.POTION, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(List.of(Material.GLASS_BOTTLE), given.stream().map(ItemStack::getType).toList()); + assertEquals(1, SaladHandler.extras(env.furniture).size()); + assertTrue(SaladHandler.isMaking(env.furniture)); + + click(vinegar("Cider")); + assertEquals("§7It already has a dressing.", messages.get(0)); + click(salt()); + ItemStack left = env.player.getInventory().getItemInMainHand(); + assertTrue(left.getType().isAir() || left.getAmount() == 0, "The last salt is used up"); + assertEquals(2, SaladHandler.extras(env.furniture).size()); + click(salt()); + assertEquals("§7There's enough dressing and seasoning in it already.", messages.get(1)); + + // An extra already in, an unreadable entry and something that isn't food don't stop a different one. + load(CONFIG.replace("max-extras: 2", "max-extras: 5")); + variables.put(SaladHandler.EXTRAS, PotReference.encodeStack(salt()) + ",missing"); + encoded.put("plain", new ItemStack(Material.STONE)); + FoodItem pepper = food("spice_1", "pepper", "Black Pepper"); + List extras = new ArrayList<>(SaladHandler.extras(env.furniture)); + extras.add(new ItemStack(Material.STONE)); + assertNull(SaladHandler.refuseExtra(extras, pepper, SaladConfig.extraFor(pepper))); + assertEquals("That's already in it.", SaladHandler.refuseExtra(extras, env.resolve(salt()), SaladConfig.extraFor(pepper))); + FoodItem nameless = food("spice_1", "spice", null); + assertNull(SaladHandler.refuseExtra(List.of(env.stack(nameless, Material.SUGAR, 1)), pepper, SaladConfig.extraFor(pepper))); + assertEquals("That's already in it.", SaladHandler.refuseExtra(List.of(env.stack(nameless, Material.SUGAR, 1)), nameless, + SaladConfig.extraFor(nameless))); + variables.put(SaladHandler.EXTRAS, 3); + assertTrue(SaladHandler.extras(env.furniture).isEmpty()); + } + + @Test + void sneakingWithAnEmptyHandTakesBackWhatWentInLast() { + click(veg("Tomato")); + click(salt()); + when(env.player.isSneaking()).thenReturn(true); + assertTrue(click(null).isCancelled()); + assertNull(in("input_1")); + assertEquals("Tomato", env.resolve(given.get(0)).getOrigin()); + click(null); + assertEquals("Salt", env.resolve(given.get(1)).getOrigin()); + assertFalse(variables.containsKey(SaladHandler.EXTRAS)); + assertFalse(SaladHandler.isMaking(env.furniture)); + } + + @Test + void tossingMakesTheSaladThatStaysInTheBowl() { + click(veg("Tomato")); + click(null); + assertEquals("§7Add at least 2 ingredients before you toss it.", messages.get(0)); + + click(cheese("Goat")); + click(veg("Olive")); + click(vinegar("Wine")); + assertTrue(click(null).isCancelled()); + assertEquals("Greek", built.getOrigin()); + assertEquals(List.of("Tomato", "Goat Cheese", "Olive"), built.getIngredients()); + assertNotNull(built.getTagTrack("dressed")); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), argThat(l -> l.size() == 4), + eq(CompositionContext.SALAD))); + assertNotNull(in("food_item")); + verify(env.active.get("food_item")).applyDisplayData(argThat(d -> d.getxScale() < 0.01f)); + assertFalse(variables.containsKey(SaladHandler.EXTRAS)); + assertFalse(SaladHandler.isMaking(env.furniture)); + + // A tossed salad is only eaten from the bowl. + assertFalse(click(veg("Corn")).isCancelled()); + assertFalse(click(null).isCancelled()); + } + + @Test + void aSaladThatCannotBeMadeStaysUntossed() { + click(veg("Tomato")); + click(veg("Onion")); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + click(null); + assertEquals("§cThis salad can't be made right now.", messages.get(0)); + assertNull(in("food_item")); + env.parser.when(() -> FoodParser.parse(anyString())).thenReturn(new FoodParser.Result()); + assertNull(SaladItems.build(env.player, List.of(veg("Tomato")), List.of())); + assertNull(SaladItems.build(env.player, List.of(), List.of(salt()))); + } + + @Test + void saladsSumWhatIsInThemAndOnlyIngredientsNameThem() { + SaladItems.build(env.player, List.of(veg("Tomato"), salt(), new ItemStack(Material.STONE), veg("Onion")), + List.of(vinegar("Wine"))); + assertEquals(List.of("Tomato", "Onion"), built.getIngredients()); + assertEquals("Beef", built.getOrigin(), "No kind takes it, so it keeps the output's origin"); + } + + @Test + void aBrokenBowlDropsWhatWasNotTossedYet() throws Exception { + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertTrue(env.dropped.isEmpty()); + click(veg("Tomato")); + click(salt()); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertEquals(2, env.dropped.size()); + assertNull(in("input_1")); + assertFalse(variables.containsKey(SaladHandler.EXTRAS)); + + env.dropped.clear(); + click(veg("Tomato")); + click(veg("Onion")); + click(null); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertTrue(env.dropped.isEmpty(), "A tossed salad goes with the bowl"); + + load(CONFIG.replace("[BOARD]", "[BOWL]")); + handler.onBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertFalse(click(veg("Corn")).isCancelled()); + assertFalse(SaladHandler.isMaking(env.furniture)); + } + + @Test + void anIngredientWithoutFoodDataStillComesBack() { + PlacedSlot slot = env.place("input_1", new ItemStack(Material.STONE)); + assertEquals(1, SaladHandler.ingredients(env.furniture).size()); + when(env.player.isSneaking()).thenReturn(true); + click(null); + assertEquals(Material.STONE, given.get(0).getType()); + verify(slot).clearModel(); + env.place("input_1", new ItemStack(Material.AIR)); + assertTrue(SaladHandler.ingredients(env.furniture).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java b/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java index 61313d9..5919466 100644 --- a/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java +++ b/src/test/java/net/tfminecraft/cooking/sandwich/DishBonusTest.java @@ -209,6 +209,24 @@ void pizzasCountCheeseSeasoningsAndGarnishesAgainstSpices() throws Exception { assertEquals("aromatic", step(kinds, "aromatic")); } + @Test + void crispNeedsEverythingWithAFreshnessStillFresh() throws Exception { + List bonuses = bonuses(""" + bonuses: + crisp: + track: crisp + fresh: true + """); + FoodItem fresh = food("jam", "jam", "Plum"); + fresh.addOrModifyTrack(new TagTrack(TrackLoader.getByString("freshness"))); + assertEquals("crisp", step(dish(List.of(fresh, food("spice", "salt", "Salt")), bonuses), "crisp")); + FoodItem stale = food("jam", "jam", "Plum"); + TagTrack age = new TagTrack(TrackLoader.getByString("freshness")); + age.forceSetValue((int) age.getSteps().get(1).getRequiredValue()); + stale.addOrModifyTrack(age); + assertNull(step(dish(List.of(fresh, stale), bonuses), "crisp")); + } + @Test void aBonusTakesItsNamedStepAndSkipsTracksThatDoNotExistOrHaveNoSteps() throws Exception { TrackLoader.oList.add(new TagTrack("empty", false, List.of()));