diff --git a/README.md b/README.md index f9dd390..43e1e69 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Cooking makes food an interactive part of everyday life. Players grow and gather - **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. Each ingredient lies in the bowl as a small heap of its pieces, in its own look for every origin and state (`models` and `spread-radius`); the tossed salad stays in the bowl with them and is eaten from it. Its ingredients name it, its kind (fruit, greek, carrot, mixed) gives it its look, and it earns bonuses such as Dressed or Crisp (`salads.yml`). - **Stock and mashed potatoes** — boil two bones in a cup of water in the saucepan and bottle the stock (`condiments.yml`); a soup started with stock instead of water is Rich. Put four boiled cut potatoes and a butter (with milk or salt if you like) in an empty pot, mash them without water or heat, and ladle the mash onto a plate beside the other food (`pot.yml`). - **Pies** — line a pie tin with dough, fill it with fruit, meat, mashed potato, vegetables or cheese, and bake it in the oven like the bread tray. The whole pie comes out of the tin with an empty hand and is cut into four slices on the cutting board. Its fillings make its kind (Apple, Key Lime, Mixed Berry, Meat, Shepherd's) and name, and its food, quality and freshness come from its dough and fillings (`pies.yml`). -- **Mushrooms and hidden poison** — grilled mushrooms go into sandwiches and salads, raw or grilled ones onto pizzas and into pies, and raw ones boil into soup; in a dish both kinds are just "Mushroom". Each red mushroom poisons the dish a step further (Tainted, Poisoned, Deadly: less and less nutrition, the same food). In a dish of 3 stars or more with its red mushrooms grilled (or its own bake cooked, its soup boiled) the poison is hidden: the lore shows no tag and the Food and Nutrition it would have without it, but eating applies it. Any dish bonus can count an origin and hide its tag this way (`count: origin`, `hide-at-stars`, `hide-tags`, `hide-dish-tags` in the dishes' `bonuses`; `tags.yml` poison). +- **Mushrooms and hidden poison** — grilled mushrooms go into sandwiches and salads, raw or grilled ones onto pizzas and into pies, and raw ones boil into soup; in a dish both kinds are just "Mushroom". Each red mushroom poisons the dish a step further (Tainted, Poisoned, Deadly: less and less nutrition, the same food, and nausea, then poison, then weakness for whoever eats it). In a dish of 3 stars or more with its red mushrooms grilled (or its own bake cooked, its soup boiled) the poison is hidden: the lore shows no tag and the Food and Nutrition it would have without it, but eating applies it. Any dish bonus can count an origin and hide its tag this way (`count: origin`, `hide-at-stars`, `hide-tags`, `hide-dish-tags` in the dishes' `bonuses`; `tags.yml` poison). Any tag step can make the eater ill with `effects` (`"poison 10 1"`: effect, seconds, level), hidden or not. - **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/item/FoodItem.java b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java index 3f299a5..89eafe1 100644 --- a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java +++ b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java @@ -12,6 +12,7 @@ import net.tfminecraft.cooking.item.model.FoodModel; import net.tfminecraft.cooking.item.model.ModelData; import net.tfminecraft.cooking.item.tag.AgeScale; +import net.tfminecraft.cooking.item.tag.TagEffect; import net.tfminecraft.cooking.item.tag.TagQuality; import net.tfminecraft.cooking.item.tag.TagStep; import net.tfminecraft.cooking.item.tag.TagTrack; @@ -679,6 +680,19 @@ private double sauceFinal(int type, boolean withHidden) { return getSauce().finalValue(type, withHidden); } + /** + * The status effects eating this food gives: those of every track's current step (tags.yml effects), hidden tracks + * included, so a well-hidden poison still strikes. + */ + public List getEatEffects() { + List effects = new ArrayList<>(); + for (TagTrack track : tags.values()) { + TagStep step = track.getCurrentStep(); + if (step != null) effects.addAll(step.getEffects()); + } + return effects; + } + private double applyMultipliers(double base, int type, boolean withHidden) { int stars = withHidden ? getEffectiveQuality() : getShownQuality(); double qualityMultiplier = type == 1 diff --git a/src/main/java/net/tfminecraft/cooking/item/tag/TagEffect.java b/src/main/java/net/tfminecraft/cooking/item/tag/TagEffect.java new file mode 100644 index 0000000..f4fac8f --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/item/tag/TagEffect.java @@ -0,0 +1,45 @@ +package net.tfminecraft.cooking.item.tag; + +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; + +/** + * A status effect a tag step gives whoever eats the food, written in tags.yml {@code effects} as + * {@code " [level]"}, e.g. {@code "poison 10 1"} for Poison I for 10 seconds. The effect is a + * Minecraft effect id (nausea, poison, weakness...); the level defaults to 1. + */ +public record TagEffect(PotionEffectType type, int seconds, int level) { + + /** The effect this line names, or null (with a warning naming the track step) when it can't be read. */ + public static TagEffect parse(String line, String where) { + String[] parts = line == null ? new String[0] : line.trim().split("\\s+"); + if (parts.length < 2 || parts.length > 3) return invalid(line, where); + NamespacedKey key = NamespacedKey.fromString(parts[0].toLowerCase(Locale.ROOT)); + PotionEffectType type = key == null ? null : Registry.MOB_EFFECT.get(key); + if (type == null) return invalid(line, where); + try { + int seconds = Integer.parseInt(parts[1]); + int level = parts.length == 3 ? Integer.parseInt(parts[2]) : 1; + if (seconds <= 0 || level < 1) return invalid(line, where); + return new TagEffect(type, seconds, level); + } catch (NumberFormatException e) { + return invalid(line, where); + } + } + + private static TagEffect invalid(String line, String where) { + Bukkit.getLogger().warning("[Cooking] Invalid tag effect '" + line + "' at " + where + + " (use \" [level]\")"); + return null; + } + + /** The effect to give: level 1 is amplifier 0, as Poison I. */ + public PotionEffect toPotionEffect() { + return new PotionEffect(type, seconds * 20, level - 1); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/item/tag/TagStep.java b/src/main/java/net/tfminecraft/cooking/item/tag/TagStep.java index ec8b77a..cede533 100644 --- a/src/main/java/net/tfminecraft/cooking/item/tag/TagStep.java +++ b/src/main/java/net/tfminecraft/cooking/item/tag/TagStep.java @@ -1,5 +1,8 @@ package net.tfminecraft.cooking.item.tag; +import java.util.ArrayList; +import java.util.List; + import org.bukkit.configuration.ConfigurationSection; import net.tfminecraft.cooking.enums.Tag; @@ -16,6 +19,7 @@ public class TagStep { private final double qualityReduce; private final double craftQualityPct; private final int stars; + private final List effects = new ArrayList<>(); public TagStep(String key, ConfigurationSection config) { this( @@ -27,6 +31,10 @@ public TagStep(String key, ConfigurationSection config) { config.getDouble("quality-reduce", 0.0), config.getDouble("craft-quality-pct", 0.0), config.getInt("stars", 0)); + for (String line : config.getStringList("effects")) { + TagEffect effect = TagEffect.parse(line, config.getCurrentPath()); + if (effect != null) effects.add(effect); + } } public TagStep(String key, String name, long requiredValue, double foodMultiplier, double nutritionMultiplier) { @@ -75,5 +83,7 @@ public long getRequiredValue() { public double getQualityReduce() { return qualityReduce; } public double getCraftQualityPct() { return craftQualityPct; } public int getStars() { return stars; } + /** The status effects eating a food at this step gives (tags.yml effects), hidden or not. */ + public List getEffects() { return List.copyOf(effects); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java index 4d139f1..f134d3d 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java @@ -1,10 +1,13 @@ package net.tfminecraft.cooking.nutrition; +import java.util.List; + import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.tag.TagEffect; public final class NutritionService { @@ -15,6 +18,8 @@ public static void tryApplyEat(Player player, FoodItem food) { NutritionLog.append("EAT_SKIP", player, null, "cause=null-input"); return; } + // The food is eaten whatever it gives in nutrition, so its effects (a hidden poison's too) always apply. + giveEatEffects(player, food); NutritionContext context = NutritionContext.capture(player); if (context == null) { NutritionLog.append("EAT_SKIP", player, null, @@ -77,6 +82,20 @@ public static void tryApplyEat(Player player, FoodItem food) { NutritionAttributeBridge.apply(context); } + /** Gives the player the status effects of the food's tag steps (tags.yml effects), hidden tracks included. */ + static void giveEatEffects(Player player, FoodItem food) { + List effects = food.getEatEffects(); + if (effects.isEmpty()) return; + StringBuilder given = new StringBuilder(); + for (TagEffect effect : effects) { + player.addPotionEffect(effect.toPotionEffect()); + if (given.length() > 0) given.append(','); + given.append(effect.type().getKey().getKey()).append(':').append(effect.seconds()).append("s:") + .append(effect.level()); + } + NutritionLog.append("EAT_EFFECTS", player, null, "food=" + food.getId() + " effects=" + given); + } + private static int applyFoodGain(RPCharacter character, int gained) { if (gained <= 0) { return 0; diff --git a/src/main/resources/tags.yml b/src/main/resources/tags.yml index 420cf26..eea880f 100644 --- a/src/main/resources/tags.yml +++ b/src/main/resources/tags.yml @@ -2,6 +2,8 @@ #########Do not change these unless you know # craft-quality-pct: upward star-roll bonus when this step is active on an extra ingredient (e.g. 0.05 = +5% craft quality) # stars: added to inherited quality while this step is active. Negative lowers the star rating. +# effects: status effects eating a food at this step gives, as " [level]" (Minecraft effect ids, +# level 1 = I), e.g. - "poison 10 1". They apply even when the track is hidden. # Age values are seconds at age.freshness 1.0 (stale = 1 day, rotten = 1.25 days). # Per-type duration is types.yml age.freshness (higher = lasts longer). freshness: @@ -293,19 +295,29 @@ rich: # each one. Food is left alone on purpose: eating moves Nutrition toward the meal's, weighted by the food it gives, so a # filling meal with no nutrition drags a diet down hardest. A dish made well (the bonus's hide-at-stars and hide-tags) # keeps it secret: no line in the lore, and the Food and Nutrition shown leave it out; eating still applies it. +# Each step also makes the eater ill (effects), hidden or not. poison: tainted: name: "#a3a04aTainted" value: 0 nutrition-mult: 0.4 + effects: + - "nausea 10" poisoned: name: "#7d9a3aPoisoned" value: 1 nutrition-mult: 0.15 + effects: + - "nausea 15" + - "poison 10 1" deadly: name: "#6b3a7dDeadly" value: 2 nutrition-mult: 0 + effects: + - "nausea 20" + - "poison 15 2" + - "weakness 60" # Popcorn machine toppings (popcorn-machines.yml): each caramel, sugar, butter or salt put in tops every piece. They name # it (naming.yml prefix-tracks: Buttered Salted Caramel Popcorn); its look comes from popcorn_look below. diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/TagEffectCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/TagEffectCoverageTest.java new file mode 100644 index 0000000..ea62902 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/nutrition/TagEffectCoverageTest.java @@ -0,0 +1,148 @@ +package net.tfminecraft.cooking.nutrition; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +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.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagEffect; +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.QualityConfig; + +/** + * Tag steps can make the eater ill (tags.yml effects): the repo's poison gives nausea, then poison, then weakness, and a + * hidden poison strikes just the same. Bad effect lines are skipped with a warning. + */ +class TagEffectCoverageTest { + @TempDir Path directory; + ServerMock server; + PlayerMock player; + List tracks; + Cooking previousPlugin; + + @BeforeEach void open() { + server = MockBukkit.mock(); + previousPlugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + player = server.addPlayer(); + tracks = new ArrayList<>(TrackLoader.oList); + new TrackLoader().load(new File("src/main/resources/tags.yml")); + QualityConfig.apply(1, 5, null, null); + NutritionConfig.load(new YamlConfiguration()); + } + + @AfterEach void close() { + NutritionLog.configure(false, false, null); + TrackLoader.oList.clear(); + TrackLoader.oList.addAll(tracks); + Cooking.plugin = previousPlugin; + MockBukkit.unmock(); + } + + static TagTrack at(String trackId, String stepId) { + TagTrack track = new TagTrack(TrackLoader.getByString(trackId)); + for (TagStep step : track.getSteps()) { + if (step.getId().equals(stepId)) track.forceSetValue((int) step.getRequiredValue()); + } + return track; + } + + static FoodItem dish(TagTrack... tracks) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", "sandwich"); + config.set("update", false); + FoodItem food = new FoodItem("sandwich", config); + food.setCategory("sandwich"); + for (TagTrack track : tracks) food.addOrModifyTrack(track); + return food; + } + + static String describe(List effects) { + List out = new ArrayList<>(); + for (TagEffect e : effects) out.add(e.type().getKey().getKey() + " " + e.seconds() + " " + e.level()); + return String.join(", ", out); + } + + @Test void theRepoPoisonGetsWorseWithEachStep() { + assertEquals("nausea 10 1", describe(at("poison", "tainted").getCurrentStep().getEffects())); + assertEquals("nausea 15 1, poison 10 1", describe(at("poison", "poisoned").getCurrentStep().getEffects())); + assertEquals("nausea 20 1, poison 15 2, weakness 60 1", + describe(at("poison", "deadly").getCurrentStep().getEffects())); + assertTrue(at("freshness", "fresh").getCurrentStep().getEffects().isEmpty()); + } + + @Test void effectLinesNameAnEffectSecondsAndAnOptionalLevel() { + TagEffect poison = TagEffect.parse(" Poison 10 2 ", "here"); + assertEquals(PotionEffectType.POISON, poison.type()); + PotionEffect potion = poison.toPotionEffect(); + assertEquals(200, potion.getDuration()); + assertEquals(1, potion.getAmplifier()); + assertEquals(1, TagEffect.parse("minecraft:nausea 5", "here").level()); + for (String bad : new String[] {null, "", "poison", "poison 1 1 1", "not_an_effect 5", "bad$key 5", + "poison ten", "poison 0", "poison 5 0"}) { + assertNull(TagEffect.parse(bad, "here"), String.valueOf(bad)); + } + } + + @Test void aStepKeepsOnlyTheEffectsItCanRead() throws Exception { + Path file = directory.resolve("tags.yml"); + Files.writeString(file, """ + sick: + sick: + name: Sick + value: 0 + effects: + - "hunger 30" + - "wobbly 30" + """); + TagStep step = new TagStep("sick", YamlConfiguration.loadConfiguration(file.toFile()).getConfigurationSection("sick.sick")); + assertEquals("hunger 30 1", describe(step.getEffects())); + assertTrue(new TagStep("plain", "Plain", 0, 1, 1).getEffects().isEmpty()); + } + + @Test void eatingAHiddenPoisonStillMakesYouIll() { + TagTrack poison = at("poison", "deadly"); + poison.setHidden(true); + FoodItem sandwich = dish(at("freshness", "fresh"), poison); + assertEquals("nausea 20 1, poison 15 2, weakness 60 1", describe(sandwich.getEatEffects())); + + // No character to feed: the food is still eaten, so it still makes the player ill. + NutritionService.tryApplyEat(player, sandwich); + assertEquals(1, player.getPotionEffect(PotionEffectType.POISON).getAmplifier()); + assertEquals(300, player.getPotionEffect(PotionEffectType.POISON).getDuration()); + assertNotNull(player.getPotionEffect(PotionEffectType.NAUSEA)); + assertEquals(1200, player.getPotionEffect(PotionEffectType.WEAKNESS).getDuration()); + } + + @Test void foodWithoutEffectsLeavesThePlayerAlone() throws Exception { + NutritionLog.configure(true, true, directory.toFile()); + NutritionService.giveEatEffects(player, dish(at("freshness", "fresh"))); + assertTrue(player.getActivePotionEffects().isEmpty()); + NutritionService.giveEatEffects(player, dish(at("poison", "tainted"))); + assertNotNull(player.getPotionEffect(PotionEffectType.NAUSEA)); + String log = Files.readString(directory.resolve("logs").resolve("nutrition.log")); + assertTrue(log.contains("EAT_EFFECTS") && log.contains("effects=nausea:10s:1"), log); + } +}