Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/net/tfminecraft/cooking/item/FoodItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TagEffect> getEatEffects() {
List<TagEffect> 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
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/net/tfminecraft/cooking/item/tag/TagEffect.java
Original file line number Diff line number Diff line change
@@ -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 "<effect> <seconds> [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 \"<effect> <seconds> [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);
}
}
10 changes: 10 additions & 0 deletions src/main/java/net/tfminecraft/cooking/item/tag/TagStep.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,6 +19,7 @@ public class TagStep {
private final double qualityReduce;
private final double craftQualityPct;
private final int stars;
private final List<TagEffect> effects = new ArrayList<>();

public TagStep(String key, ConfigurationSection config) {
this(
Expand All @@ -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) {
Expand Down Expand Up @@ -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<TagEffect> getEffects() { return List.copyOf(effects); }
}

Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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,
Expand Down Expand Up @@ -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<TagEffect> 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;
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/tags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<effect> <seconds> [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:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TagTrack> 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<TagEffect> effects) {
List<String> 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);
}
}
Loading