From 17e31ce5b0c201774ab293729d5c48e5d2784d0e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:25:47 +0000 Subject: [PATCH] feat: scale breeding gene bonus by feed quality Lower-star wheat, carrots, and other breeding food pass on less of the gene bump. Five-star food and universal feed keep the current roll. Co-authored-by: Cursor --- .../husbandry/HusbandryBreedListener.java | 37 ++++- .../husbandry/HusbandryFeedQuality.java | 151 ++++++++++++++++++ .../cooking/husbandry/HusbandryGenetics.java | 13 +- src/main/resources/husbandry.yml | 2 + .../husbandry/HusbandryFeedQualityTest.java | 67 ++++++++ .../husbandry/HusbandryGeneticsTest.java | 31 ++++ 6 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQuality.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQualityTest.java diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java index 392c7db..06d3ec2 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java @@ -13,16 +13,39 @@ import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityBreedEvent; import org.bukkit.event.entity.EntityEnterLoveModeEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; public final class HusbandryBreedListener implements Listener { + @EventHandler(ignoreCancelled = true) + public void onFeed(PlayerInteractEntityEvent event) { + if (!(event.getRightClicked() instanceof LivingEntity living)) { + return; + } + if (!HusbandryConfig.isHusbandryType(living.getType())) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = event.getHand() == EquipmentSlot.OFF_HAND + ? player.getInventory().getItemInOffHand() + : player.getInventory().getItemInMainHand(); + HusbandryFeedQuality.offer( + living.getUniqueId(), + HusbandryFeedQuality.scaleOf(hand), + System.currentTimeMillis()); + } + @EventHandler(ignoreCancelled = true) public void onEnterLove(EntityEnterLoveModeEvent event) { Entity entity = event.getEntity(); BlockReason reason = cannotBreed(entity); if (reason == BlockReason.NONE) { + HusbandryFeedQuality.commitOffer(entity.getUniqueId(), System.currentTimeMillis()); return; } + HusbandryFeedQuality.discardOffer(entity.getUniqueId()); event.setCancelled(true); clearLove(entity); Player feeder = event.getHumanEntity() instanceof Player player ? player : null; @@ -60,7 +83,7 @@ public void onBreed(EntityBreedEvent event) { return; } - persistBaby(child, mother, father); + persistBaby(child, mother, father, event.getBredWith()); scheduleMountStats(child); } @@ -129,7 +152,11 @@ private static BlockReason cannotBreed(Entity entity) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private static void persistBaby(LivingEntity child, LivingEntity mother, LivingEntity father) { + private static void persistBaby( + LivingEntity child, + LivingEntity mother, + LivingEntity father, + ItemStack bredWith) { if (child == null) { return; } @@ -147,15 +174,17 @@ private static void persistBaby(LivingEntity child, LivingEntity mother, LivingE int fatherGenetics = fatherAnimal == null ? 0 : fatherAnimal.genetics(); int motherCare = motherAnimal == null ? 0 : motherAnimal.care(); int fatherCare = fatherAnimal == null ? 0 : fatherAnimal.care(); + long now = System.currentTimeMillis(); + double feedScale = HusbandryFeedQuality.consumeBreedingScale( + mother.getUniqueId(), father.getUniqueId(), bredWith, now); int genetics = HusbandryGenetics.roll( - motherGenetics, fatherGenetics, motherCare, fatherCare, ThreadLocalRandom.current()); + motherGenetics, fatherGenetics, motherCare, fatherCare, ThreadLocalRandom.current(), feedScale); String name = HusbandryEntities.displayName(child.getType()); child.setCustomName(name); child.setCustomNameVisible(false); HusbandryEntities.applyPersistFlags(child); HusbandryEntities.stampManaged(child); - long now = System.currentTimeMillis(); HusbandryAnimal baby = new HusbandryAnimal(uuid, child.getType().name(), name); baby.setState(HusbandryAnimalState.UNTAMED); baby.setGenetics(genetics); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQuality.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQuality.java new file mode 100644 index 0000000..8e02acf --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQuality.java @@ -0,0 +1,151 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.cooking.utils.QualityUtils; + +/** + * Breeding-food stars scale the gene bonus above the parent average. + * Five stars, universal feed, and items with no cooking quality keep the full bonus. + */ +public final class HusbandryFeedQuality { + + public static final double FULL_SCALE = 1.0; + private static final long OFFER_WINDOW_MILLIS = 2_000L; + private static final long LOVE_WINDOW_MILLIS = 45_000L; + + private static final ConcurrentHashMap OFFERS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap FEEDS = new ConcurrentHashMap<>(); + + private HusbandryFeedQuality() {} + + public static double scaleForStars(int stars) { + int clamped = Math.max(1, Math.min(5, stars)); + return clamped / 5.0; + } + + public static double clampScale(double scale) { + if (Double.isNaN(scale)) { + return FULL_SCALE; + } + return Math.max(0, Math.min(FULL_SCALE, scale)); + } + + public static double combine(Double left, Double right) { + if (left == null && right == null) { + return FULL_SCALE; + } + if (left == null) { + return clampScale(right); + } + if (right == null) { + return clampScale(left); + } + return clampScale((left + right) / 2.0); + } + + public static double scaleOf(ItemStack stack) { + if (isUniversalFeed(stack)) { + return FULL_SCALE; + } + Integer stars = starsOrNull(stack); + if (stars == null) { + return FULL_SCALE; + } + return scaleForStars(stars); + } + + public static void offer(UUID animalId, double scale, long nowMillis) { + if (animalId == null) { + return; + } + OFFERS.put(animalId, new TimedScale(clampScale(scale), nowMillis)); + } + + public static void discardOffer(UUID animalId) { + if (animalId != null) { + OFFERS.remove(animalId); + } + } + + public static void commitOffer(UUID animalId, long nowMillis) { + if (animalId == null) { + return; + } + TimedScale offer = OFFERS.remove(animalId); + if (offer == null || nowMillis - offer.atMillis > OFFER_WINDOW_MILLIS) { + return; + } + remember(animalId, offer.scale, nowMillis); + } + + public static void remember(UUID animalId, double scale, long nowMillis) { + if (animalId == null) { + return; + } + FEEDS.put(animalId, new TimedScale(clampScale(scale), nowMillis)); + } + + public static double consumeBreedingScale(UUID mother, UUID father, ItemStack bredWith, long nowMillis) { + Double motherScale = take(mother, nowMillis); + Double fatherScale = take(father, nowMillis); + if (motherScale == null && fatherScale == null) { + return scaleOf(bredWith); + } + return combine(motherScale, fatherScale); + } + + public static double takePair(UUID mother, UUID father, long nowMillis) { + return combine(take(mother, nowMillis), take(father, nowMillis)); + } + + static void clear() { + OFFERS.clear(); + FEEDS.clear(); + } + + private static Double take(UUID animalId, long nowMillis) { + if (animalId == null) { + return null; + } + TimedScale feed = FEEDS.remove(animalId); + if (feed == null || nowMillis - feed.atMillis > LOVE_WINDOW_MILLIS) { + return null; + } + return feed.scale; + } + + private static boolean isUniversalFeed(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return false; + } + try { + return HusbandryItems.matches(stack, HusbandryConfig.feedItem()); + } catch (RuntimeException ignored) { + return false; + } + } + + private static Integer starsOrNull(ItemStack stack) { + if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) { + return null; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return null; + } + Integer stored = meta.getPersistentDataContainer().get(Keys.QUALITY, PersistentDataType.INTEGER); + if (stored == null) { + return null; + } + return QualityUtils.clamp(stored); + } + + private record TimedScale(double scale, long atMillis) {} +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGenetics.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGenetics.java index 82bc799..5fdfc9d 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGenetics.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGenetics.java @@ -12,6 +12,16 @@ public static int roll( int motherCare, int fatherCare, Random random) { + return roll(motherGenetics, fatherGenetics, motherCare, fatherCare, random, HusbandryFeedQuality.FULL_SCALE); + } + + public static int roll( + int motherGenetics, + int fatherGenetics, + int motherCare, + int fatherCare, + Random random, + double feedScale) { int maxGenetics = HusbandryConfig.maxGenetics(); int avg = (motherGenetics + fatherGenetics) / 2; double varianceBase = maxGenetics / 10.0; @@ -25,7 +35,8 @@ public static int roll( double careAvg = (motherCare + fatherCare) / 2.0; double careRatio = careMax <= 0 ? 0 : Math.max(0, Math.min(1, careAvg / careMax)); int careExtra = (int) (HusbandryConfig.careInfluence() * maxGenetics * careRatio); - int rolled = avg + (int) (bonus * careRatio) + careExtra; + int boost = (int) (bonus * careRatio) + careExtra; + int rolled = avg + (int) (boost * HusbandryFeedQuality.clampScale(feedScale)); return Math.max(0, Math.min(maxGenetics, rolled)); } } diff --git a/src/main/resources/husbandry.yml b/src/main/resources/husbandry.yml index 62b9ac0..d89843c 100644 --- a/src/main/resources/husbandry.yml +++ b/src/main/resources/husbandry.yml @@ -48,6 +48,8 @@ breeding: genetic-slowdown-divisor: 0.6 # Extra gene points at full parent care: this * max-genetics (0.02 -> +20 at 1000). care-influence: 0.02 + # Breeding-food stars scale that bonus (1★ = 20% … 5★ = 100%, same roll as before). + # Universal feed and items with no cooking quality count as a full bonus. amount-from-genetics: - min: 0 diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQualityTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQualityTest.java new file mode 100644 index 0000000..cb0bc50 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryFeedQualityTest.java @@ -0,0 +1,67 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HusbandryFeedQualityTest { + + @BeforeEach + void resetFeeds() { + HusbandryFeedQuality.clear(); + } + + @Test + void starsScaleLinearlyToAFullBonus() { + assertEquals(0.2, HusbandryFeedQuality.scaleForStars(1), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.scaleForStars(5), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.scaleForStars(9), 1e-9); + } + + @Test + void missingFeedsKeepTheFullBonus() { + assertEquals(1.0, HusbandryFeedQuality.combine(null, null), 1e-9); + assertEquals(0.2, HusbandryFeedQuality.combine(0.2, null), 1e-9); + } + + @Test + void rememberedFeedsAverageUntilTheyExpire() { + UUID mother = UUID.randomUUID(); + UUID father = UUID.randomUUID(); + long now = 5_000L; + HusbandryFeedQuality.remember(mother, HusbandryFeedQuality.scaleForStars(1), now); + HusbandryFeedQuality.remember(father, HusbandryFeedQuality.scaleForStars(5), now); + assertEquals(0.6, HusbandryFeedQuality.takePair(mother, father, now), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.takePair(mother, father, now), 1e-9); + } + + @Test + void offerCommitsOnlyInsideTheClickWindow() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.offer(animal, 0.4, 1_000L); + HusbandryFeedQuality.commitOffer(animal, 1_000L); + assertEquals(0.4, HusbandryFeedQuality.takePair(animal, null, 1_000L), 1e-9); + + HusbandryFeedQuality.offer(animal, 0.4, 1_000L); + HusbandryFeedQuality.commitOffer(animal, 4_000L); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 4_000L), 1e-9); + } + + @Test + void expiredLoveFeedIsIgnored() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.remember(animal, 0.2, 1_000L); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 1_000L + 45_001L), 1e-9); + } + + @Test + void clearDropsRememberedFeeds() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.remember(animal, 0.2, 1_000L); + HusbandryFeedQuality.clear(); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 1_000L), 1e-9); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryGeneticsTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryGeneticsTest.java index b2e31d6..221f883 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryGeneticsTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryGeneticsTest.java @@ -51,6 +51,37 @@ void resultIsClampedToZero() { assertEquals(0, child); } + @Test + void fiveStarFeedMatchesTheUnscaledRoll() { + int full = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus()); + int fiveStars = HusbandryGenetics.roll( + 10, 10, 200, 200, maxBonus(), HusbandryFeedQuality.scaleForStars(5)); + assertEquals(68, full); + assertEquals(full, fiveStars); + } + + @Test + void oneStarFeedKeepsOneFifthOfTheBoost() { + int child = HusbandryGenetics.roll( + 10, 10, 200, 200, maxBonus(), HusbandryFeedQuality.scaleForStars(1)); + assertEquals(21, child); + } + + @Test + void mixedOneAndFiveStarFeedsUseTheAverage() { + double scale = HusbandryFeedQuality.combine( + HusbandryFeedQuality.scaleForStars(1), + HusbandryFeedQuality.scaleForStars(5)); + int child = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus(), scale); + assertEquals(44, child); + } + + @Test + void zeroCareIgnoresFeedQuality() { + assertEquals(10, HusbandryGenetics.roll( + 10, 10, 0, 0, maxBonus(), HusbandryFeedQuality.scaleForStars(1))); + } + private static Random maxBonus() { return new Random() { @Override