diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java b/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java index 5d2d936..9780f31 100644 --- a/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java +++ b/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java @@ -77,7 +77,7 @@ public static double remainingFood(FoodItem item, CarveSequence seq) { int edible = Math.max(1, countFoodCuts(seq)); return item.getBaseFood() * countFoodCutsFrom(seq, item.getCarveNextIndex()) / (double) edible; } - return seq.sumRemainingFood(item.getCarveNextIndex()); + return budgetedFood(seq, item.getCarveNextIndex(), budgetedFoodCuts(item, seq)); } public static double portionFood(double totalFood, int edibleCuts) { @@ -103,9 +103,62 @@ public static int countFoodCutsFrom(CarveSequence seq, int fromIndex) { return count; } - /** Visual stage for IA models: raw_1 = whole bird, raw_6 = mostly carved. */ + /** + * Meat portions a slaughtered animal yields, then the closing bone when the sequence has one. + * The roast always starts at the first cut. Genetics only changes how many meat cuts follow + * before that bone, never below the sequence floor. + */ + public record RoastPortion(int nextIndex, int remaining) {} + + public static RoastPortion portion(CarveSequence seq, int requestedMeatCuts) { + int food = countFoodCuts(seq); + int min = food == 0 ? 0 : Math.min(food, Math.max(1, seq.getMinFoodCuts())); + int meat = Math.max(min, Math.min(food, requestedMeatCuts)); + int bone = trailingItemIndex(seq) >= 0 ? 1 : 0; + return new RoastPortion(0, meat + bone); + } + + /** Index of a non-food cut that closes the sequence, or -1 when every cut is meat. */ + public static int trailingItemIndex(CarveSequence seq) { + if (seq == null) return -1; + List cuts = seq.getCuts(); + for (int i = cuts.size() - 1; i >= 0; i--) { + CarveCut cut = cuts.get(i); + if (cut.isItemCut()) return i; + if (cut.isFoodCut()) return -1; + } + return -1; + } + + /** Visual stage for IA models: stage 1 is the whole roast, the last stage is bone. */ public static int getVisualCarveStage(FoodItem item) { - return item.getCarveNextIndex() + 1; + return visualCarveStage(item, getSequence(item)); + } + + public static int visualCarveStage(FoodItem item, CarveSequence seq) { + if (item == null) return 1; + if (seq == null || seq.getCuts().isEmpty()) { + return Math.max(1, item.getCarveNextIndex() + 1); + } + int maxStage = Math.max(seq.getStartRemaining(), seq.getCuts().size()); + maxStage = Math.max(1, maxStage); + CarveCut next = seq.getCut(item.getCarveNextIndex()); + if (next != null && !next.isFoodCut()) { + return maxStage; + } + boolean bone = trailingItemIndex(seq) >= 0; + int meatLeft = bone + ? Math.max(0, item.getCarveRemaining() - 1) + : Math.max(0, item.getCarveRemaining()); + int meatTaken = Math.max(0, item.getCarveNextIndex()); + int meatTotal = meatTaken + meatLeft; + if (meatTotal <= 0 || meatTaken <= 0) { + return meatTaken <= 0 ? 1 : maxStage; + } + int span = bone ? meatTotal : Math.max(1, meatTotal - 1); + int stage = 1 + (int) Math.round(meatTaken * (double) (maxStage - 1) / span); + if (stage < 1) return 1; + return Math.min(maxStage, stage); } public static ModelData getStageModelData(FoodItem item) { @@ -135,8 +188,46 @@ public static String resolveCookTag(FoodItem item) { } public static void advanceAfterCarve(FoodItem item) { - item.setCarveNextIndex(item.getCarveNextIndex() + 1); - item.setCarveRemaining(Math.max(0, item.getCarveRemaining() - 1)); + advanceAfterCarve(item, getSequence(item)); + } + + public static void advanceAfterCarve(FoodItem item, CarveSequence seq) { + int next = item.getCarveNextIndex() + 1; + int remaining = Math.max(0, item.getCarveRemaining() - 1); + if (remaining == 1 && seq != null) { + int bone = trailingItemIndex(seq); + CarveCut upcoming = seq.getCut(next); + if (bone >= 0 && (upcoming == null || upcoming.isFoodCut())) { + next = bone; + } + } + item.setCarveNextIndex(next); + item.setCarveRemaining(remaining); + } + + private static int budgetedFoodCuts(FoodItem item, CarveSequence seq) { + CarveCut next = seq.getCut(item.getCarveNextIndex()); + if (next != null && !next.isFoodCut()) { + return 0; + } + if (trailingItemIndex(seq) >= 0) { + return Math.max(0, item.getCarveRemaining() - 1); + } + return Math.max(0, item.getCarveRemaining()); + } + + private static double budgetedFood(CarveSequence seq, int fromIndex, int foodCuts) { + if (seq == null || foodCuts <= 0) return 0; + double total = 0; + int counted = 0; + List cuts = seq.getCuts(); + for (int i = Math.max(0, fromIndex); i < cuts.size() && counted < foodCuts; i++) { + CarveCut cut = cuts.get(i); + if (!cut.isFoodCut()) continue; + total += cut.getFood(); + counted++; + } + return total; } public static void copyInheritedTracks(FoodItem parent, FoodItem child) { diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarveSequence.java b/src/main/java/net/tfminecraft/cooking/carve/CarveSequence.java index 20a6295..0dbf788 100644 --- a/src/main/java/net/tfminecraft/cooking/carve/CarveSequence.java +++ b/src/main/java/net/tfminecraft/cooking/carve/CarveSequence.java @@ -6,11 +6,17 @@ public class CarveSequence { private final String id; private final int startRemaining; + private final int minFoodCuts; private final List cuts = new ArrayList<>(); public CarveSequence(String id, int startRemaining, List cuts) { + this(id, startRemaining, 1, cuts); + } + + public CarveSequence(String id, int startRemaining, int minFoodCuts, List cuts) { this.id = id; this.startRemaining = startRemaining; + this.minFoodCuts = minFoodCuts; this.cuts.addAll(cuts); } @@ -22,6 +28,11 @@ public int getStartRemaining() { return startRemaining; } + /** Meat portions a slaughtered animal of this sequence always keeps before the bone. */ + public int getMinFoodCuts() { + return minFoodCuts; + } + public List getCuts() { return cuts; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java index abdc0ac..ff0aa07 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java @@ -14,7 +14,6 @@ import net.tfminecraft.cooking.carve.CarvableRoastUtils; import net.tfminecraft.cooking.carve.CarveSequence; import net.tfminecraft.cooking.item.FoodItem; -import net.tfminecraft.cooking.item.model.ModelData; import net.tfminecraft.cooking.loader.CarveSequenceLoader; import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.InventoryAdder; @@ -190,21 +189,14 @@ public static ItemStack buildFood(HusbandryAnimal animal, String foodString, Ite if (seq == null) { return stack; } - int maxCuts = Math.max(1, seq.getStartRemaining()); - int cuts = Math.min(maxCuts, HusbandryConfig.roastCutsFor(HusbandryConfig.effectiveGenetics(animal))); - int nextIndex = Math.max(0, maxCuts - cuts); - item.setCarveState(seqId, nextIndex, cuts); + int requested = HusbandryConfig.roastCutsFor(HusbandryConfig.effectiveGenetics(animal)); + CarvableRoastUtils.RoastPortion portion = CarvableRoastUtils.portion(seq, requested); + item.setCarveState(seqId, portion.nextIndex(), portion.remaining()); CarvableRoastUtils.writeCarveState(stack, item); ItemStack updated = ItemUpdater.applyItemUpdate(stack, item, null); if (updated != null) { stack = updated; } - ModelData staged = item.getModel() == null - ? null - : item.getModel().getModelByStageAndTag(cuts, CarvableRoastUtils.resolveCookTag(item)); - if (staged != null) { - stack = staged.apply(null, stack); - } return stack; } diff --git a/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java b/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java index 76db702..81633eb 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java @@ -41,6 +41,9 @@ public void load(File file) { if (sec == null) continue; int startRemaining = sec.getInt("start-remaining", 0); + int minFoodCuts = sec.contains("min-food-cuts") + ? sec.getInt("min-food-cuts") + : defaultMinFoodCuts(key); List cuts = new ArrayList<>(); for (Map map : sec.getMapList("cuts")) { @@ -70,7 +73,15 @@ public void load(File file) { } } - sequences.put(key.toLowerCase(), new CarveSequence(key, startRemaining, cuts)); + sequences.put(key.toLowerCase(), new CarveSequence(key, startRemaining, minFoodCuts, cuts)); } } + + /** Used when an older carve-sequences.yml has no min-food-cuts key. */ + static int defaultMinFoodCuts(String key) { + if ("poultry".equalsIgnoreCase(key)) { + return 3; + } + return 1; + } } diff --git a/src/main/resources/carve-sequences.yml b/src/main/resources/carve-sequences.yml index f699ff5..7fe0ee3 100644 --- a/src/main/resources/carve-sequences.yml +++ b/src/main/resources/carve-sequences.yml @@ -1,5 +1,7 @@ poultry: start-remaining: 6 + # Slaughter never drops below two legs and one filet, then the bone. + min-food-cuts: 3 cuts: - output: "meat(type=meat_poultry_leg;origin=Chicken)" food: 20.0 @@ -23,6 +25,8 @@ poultry: red_meat: start-remaining: 8 + # Slaughter never drops below one steak, then the bone. + min-food-cuts: 1 cuts: - output: "meat(type=meat_red_meat;origin=Beef)" food: 18.0 diff --git a/src/main/resources/husbandry.yml b/src/main/resources/husbandry.yml index a15e8cb..62b9ac0 100644 --- a/src/main/resources/husbandry.yml +++ b/src/main/resources/husbandry.yml @@ -35,6 +35,8 @@ exp: initial-genetic-max: 20 max-genetics: 1000 +# Meat portions from effective genetics, before the closing bone. +# Poultry raises this floor to two legs and one filet (carve-sequences.yml). min-roast-cuts: 1 # Bump this string to wild-reset animals with a missing or old id (genetics + care). stats-revision: "1" diff --git a/src/test/java/net/tfminecraft/cooking/item/RoastPortionTest.java b/src/test/java/net/tfminecraft/cooking/item/RoastPortionTest.java new file mode 100644 index 0000000..d5a64d5 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/RoastPortionTest.java @@ -0,0 +1,140 @@ +package net.tfminecraft.cooking.item; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.cooking.carve.CarveCut; +import net.tfminecraft.cooking.carve.CarveSequence; +import net.tfminecraft.cooking.carve.CarvableRoastUtils; + +class RoastPortionTest { + + @Test + void redMeatFloorIsOneSteakThenBoneAndStartsWhole() { + CarveSequence sequence = redMeat(); + FoodItem roast = place(sequence, 1); + + assertEquals(0, roast.getCarveNextIndex()); + assertEquals(2, roast.getCarveRemaining()); + assertEquals(1, CarvableRoastUtils.visualCarveStage(roast, sequence)); + assertEquals(18, CarvableRoastUtils.remainingFood(roast, sequence)); + assertEquals(List.of("meat_red_meat", "v.bone"), carve(roast, sequence)); + } + + @Test + void poultryFloorIsTwoLegsOneFiletThenBone() { + CarveSequence sequence = poultry(); + FoodItem roast = place(sequence, 1); + + assertEquals(0, roast.getCarveNextIndex()); + assertEquals(4, roast.getCarveRemaining()); + assertEquals(1, CarvableRoastUtils.visualCarveStage(roast, sequence)); + assertEquals(58, CarvableRoastUtils.remainingFood(roast, sequence)); + assertEquals(List.of( + "meat_poultry_leg", + "meat_poultry_leg", + "meat_poultry", + "v.bone"), carve(roast, sequence)); + } + + @Test + void higherGeneticsAddsMeatButStillClosesOnBone() { + FoodItem beef = place(redMeat(), 4); + assertEquals(5, beef.getCarveRemaining()); + assertEquals(List.of( + "meat_red_meat", + "meat_red_meat", + "meat_red_meat", + "meat_red_meat", + "v.bone"), carve(beef, redMeat())); + + FoodItem chicken = place(poultry(), 4); + assertEquals(5, chicken.getCarveRemaining()); + assertEquals(List.of( + "meat_poultry_leg", + "meat_poultry_leg", + "meat_poultry", + "meat_poultry", + "v.bone"), carve(chicken, poultry())); + } + + @Test + void fullSequencesKeepOneStagePerCut() { + assertStages(redMeat(), 8); + assertStages(poultry(), 6); + assertStages(sausage(), 5); + } + + private static void assertStages(CarveSequence sequence, int cuts) { + FoodItem roast = new FoodItem("roast", "Roast", true); + CarvableRoastUtils.RoastPortion portion = CarvableRoastUtils.portion(sequence, cuts); + roast.setCarveState(sequence.getId(), portion.nextIndex(), portion.remaining()); + for (int taken = 0; taken < cuts; taken++) { + int expected = sequence.getCut(roast.getCarveNextIndex()).isFoodCut() + ? taken + 1 + : cuts; + assertEquals(expected, CarvableRoastUtils.visualCarveStage(roast, sequence), + sequence.getId() + " after " + taken); + CarvableRoastUtils.advanceAfterCarve(roast, sequence); + } + assertEquals(0, roast.getCarveRemaining()); + } + + private static FoodItem place(CarveSequence sequence, int requestedMeat) { + FoodItem roast = new FoodItem("roast", "Roast", true); + CarvableRoastUtils.RoastPortion portion = CarvableRoastUtils.portion(sequence, requestedMeat); + roast.setCarveState(sequence.getId(), portion.nextIndex(), portion.remaining()); + return roast; + } + + private static List carve(FoodItem roast, CarveSequence sequence) { + List yields = new ArrayList<>(); + while (roast.getCarveRemaining() > 0) { + CarveCut cut = sequence.getCut(roast.getCarveNextIndex()); + yields.add(cut.isFoodCut() ? cut.getOutput() : cut.getItemRef()); + if (yields.size() == 1) { + assertEquals(1, CarvableRoastUtils.visualCarveStage(roast, sequence)); + } + if (!cut.isFoodCut()) { + assertEquals(sequence.getCuts().size(), CarvableRoastUtils.visualCarveStage(roast, sequence)); + } + CarvableRoastUtils.advanceAfterCarve(roast, sequence); + } + return yields; + } + + private static CarveSequence poultry() { + return new CarveSequence("poultry", 6, 3, List.of( + CarveCut.food("meat_poultry_leg", 20, 8), + CarveCut.food("meat_poultry_leg", 20, 8), + CarveCut.food("meat_poultry", 18, 8), + CarveCut.food("meat_poultry", 18, 8), + CarveCut.food("meat_poultry", 18, 8), + CarveCut.item("v.bone", 2, 0, 0))); + } + + private static CarveSequence redMeat() { + return new CarveSequence("red_meat", 8, 1, List.of( + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.food("meat_red_meat", 18, 8), + CarveCut.item("v.bone", 4, 0, 0))); + } + + private static CarveSequence sausage() { + return new CarveSequence("sausage", 5, 1, List.of( + CarveCut.food("sausage", 18, 8), + CarveCut.food("sausage", 18, 8), + CarveCut.food("sausage", 18, 8), + CarveCut.food("sausage", 18, 8), + CarveCut.food("sausage", 18, 8))); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/loader/CarveSequenceLoaderTest.java b/src/test/java/net/tfminecraft/cooking/loader/CarveSequenceLoaderTest.java new file mode 100644 index 0000000..e4628fb --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/loader/CarveSequenceLoaderTest.java @@ -0,0 +1,14 @@ +package net.tfminecraft.cooking.loader; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class CarveSequenceLoaderTest { + + @Test + void missingFloorKeepsPoultryAtTwoLegsAndAFilet() { + assertEquals(3, CarveSequenceLoader.defaultMinFoodCuts("poultry")); + assertEquals(1, CarveSequenceLoader.defaultMinFoodCuts("red_meat")); + } +}