diff --git a/src/blobbi/core/lib/blobbi-decay.test.ts b/src/blobbi/core/lib/blobbi-decay.test.ts index 195cd4b8..e7ad283f 100644 --- a/src/blobbi/core/lib/blobbi-decay.test.ts +++ b/src/blobbi/core/lib/blobbi-decay.test.ts @@ -24,6 +24,172 @@ function decay(overrides: { }); } +// ─── Baby awake ─────────────────────────────────────────────────────────────── + +describe('baby awake decay', () => { + it('1 hour from full stats: decays at tuned rates', () => { + const r = decay({ stage: 'baby', state: 'active', elapsedSeconds: 3600 }); + // hunger: 100 + trunc(-8) = 92 + // happiness: 100 + trunc(-4.5) = 96 + // hygiene: 100 + trunc(-6) = 94 + // energy: 100 + trunc(-9) = 91 + expect(r.stats.hunger).toBe(92); + expect(r.stats.happiness).toBe(96); + expect(r.stats.hygiene).toBe(94); + expect(r.stats.energy).toBe(91); + }); + + it('1 hour from full: health regens (all stats after decay ≥ 76)', () => { + const r = decay({ stage: 'baby', state: 'active', elapsedSeconds: 3600 }); + // After deltas: hunger=92, happiness=96, hygiene=94, energy=91 — all ≥ 76. + // healthDelta = -0.4 + 1.5 = 1.1, trunc(1.1) = 1. health = 101 → 100. + expect(r.stats.health).toBe(100); + }); +}); + +// ─── Baby health penalty alignment ─────────────────────────────────────────── + +describe('baby health penalties aligned to segment boundaries', () => { + it('stats in "okay" range (60): no penalties, health barely decays', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 60, happiness: 100, health: 100, hygiene: 60, energy: 100 }, + elapsedSeconds: 3600, + }); + // hunger after: 60 + trunc(-8) = 52 (still ≥ 50, no penalty) + // hygiene after: 60 + trunc(-6) = 54 (still ≥ 50, no penalty) + // happiness after: 96, energy after: 91. Neither < 50. + // healthDelta = -0.4 (base only), no penalties, no regen (hunger 52 < 76). + // trunc(-0.4) = 0 → health stays 100. + expect(r.stats.health).toBe(100); + expect(r.stats.hunger).toBe(52); + expect(r.stats.hygiene).toBe(54); + }); + + it('stats in "attention" range (40): mild penalties apply', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 40, happiness: 100, health: 100, hygiene: 40, energy: 100 }, + elapsedSeconds: 3600, + }); + // hunger after: 40 + trunc(-8) = 32 (≤ 50, mild penalty) + // hygiene after: 40 + trunc(-6) = 34 (≤ 50, mild penalty) + // healthDelta = -0.4 (base) + -0.5 (hunger≤50) + -0.5 (hygiene≤50) = -1.4 + // trunc(-1.4) = -1 → health = 99. + expect(r.stats.health).toBe(99); + }); + + it('stats in "urgent" range (15): strong penalties stack', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 15, happiness: 100, health: 100, hygiene: 15, energy: 100 }, + elapsedSeconds: 3600, + }); + // hunger after: 15 + trunc(-8) = 7 (≤ 50 + ≤ 25) + // hygiene after: 15 + trunc(-6) = 9 (≤ 50 + ≤ 25) + // healthDelta = -0.4 + -0.5 + -1.0 + -0.5 + -1.0 = -3.4 + // trunc(-3.4) = -3 → health = 97. + expect(r.stats.health).toBe(97); + }); + + it('penalty fires at exact boundary (hunger decays to exactly 50)', () => { + const r = decay({ + stage: 'baby', + state: 'active', + // hunger 90 → 90 + trunc(-8*5) = 50. Exactly at attention boundary. + stats: { hunger: 90, happiness: 100, health: 100, hygiene: 100, energy: 100 }, + elapsedSeconds: 3600 * 5, + }); + // hunger after = 50. careState "attention" starts at ≤ 50, penalty must fire. + // happiness: 100 + trunc(-4.5*5) = 78. hygiene: 100 + trunc(-6*5) = 70. energy: 100 + trunc(-9*5) = 55. + // No other stat ≤ 50 → only hunger penalty. + // healthDelta = -0.4*5 + -0.5*5 = -4.5, trunc(-4.5) = -4. health = 96. + expect(r.stats.hunger).toBe(50); + expect(r.stats.health).toBe(96); + }); + + it('all stats urgent: maximum penalty pressure', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 10, happiness: 10, health: 80, hygiene: 10, energy: 10 }, + elapsedSeconds: 3600, + }); + // After deltas: hunger=2, happiness=6, hygiene=4, energy=1. + // All four stats ≤ 50 AND ≤ 25 → all mild + strong penalties fire. + // healthDelta = -0.4 (base) + // + 4 × -0.5 (mild) + 4 × -1.0 (strong) = -0.4 - 2.0 - 4.0 = -6.4 + // trunc(-6.4) = -6 → health = 80 - 6 = 74. + expect(r.stats.health).toBe(74); + }); +}); + +// ─── Baby health regen threshold ────────────────────────────────────────────── + +describe('baby health regen threshold (≥ 76)', () => { + it('all stats = 85: regens (after decay all ≥ 76)', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 85, happiness: 85, health: 85, hygiene: 85, energy: 85 }, + elapsedSeconds: 3600, + }); + // After deltas: hunger=77, happiness=81, hygiene=79, energy=76 — all ≥ 76. + // healthDelta = -0.4 + 1.5 = 1.1, trunc(1.1) = 1. + expect(r.stats.health).toBe(86); + }); + + it('all stats = 76: does NOT regen (after decay < 76)', () => { + const r = decay({ + stage: 'baby', + state: 'active', + stats: { hunger: 76, happiness: 76, health: 76, hygiene: 76, energy: 76 }, + elapsedSeconds: 3600, + }); + // After deltas: hunger=68, happiness=72, hygiene=70, energy=67 — NOT all ≥ 76. + // healthDelta = -0.4, no regen. trunc(-0.4) = 0 → health stays 76. + expect(r.stats.health).toBe(76); + }); +}); + +// ─── Adult awake ────────────────────────────────────────────────────────────── + +describe('adult awake decay', () => { + it('1 hour from full stats: decays at tuned rates', () => { + const r = decay({ stage: 'adult', state: 'active', elapsedSeconds: 3600 }); + // hunger: 100 + trunc(-5) = 95 + // happiness: 100 + trunc(-2.5) = 98 + // hygiene: 100 + trunc(-4) = 96 + // energy: 100 + trunc(-5.5) = 95 + expect(r.stats.hunger).toBe(95); + expect(r.stats.happiness).toBe(98); + expect(r.stats.hygiene).toBe(96); + expect(r.stats.energy).toBe(95); + }); + + it('1 hour from full: health stays at 100 (regen cancels base)', () => { + const r = decay({ stage: 'adult', state: 'active', elapsedSeconds: 3600 }); + // After deltas all ≥ 80. healthDelta = -0.25 + 1.0 = 0.75. trunc(0.75) = 0. + expect(r.stats.health).toBe(100); + }); + + it('adult penalty thresholds unchanged: hunger < 60 triggers mild', () => { + const r = decay({ + stage: 'adult', + state: 'active', + stats: { hunger: 55, happiness: 100, health: 100, hygiene: 100, energy: 100 }, + elapsedSeconds: 3600, + }); + // hunger after: 55 + trunc(-5) = 50 (< 60, mild penalty fires) + // healthDelta = -0.25 + -0.5 = -0.75, trunc = 0 → health stays 100. + expect(r.stats.health).toBe(100); + expect(r.stats.hunger).toBe(50); + }); +}); + // ─── Baby sleeping ──────────────────────────────────────────────────────────── describe('baby sleeping decay', () => { @@ -33,36 +199,36 @@ describe('baby sleeping decay', () => { }); it('1 hour: hunger decays only 20% of awake rate', () => { - // Awake hunger rate = -7.0/hr → sleeping = -7.0 * 0.2 = -1.4/hr - // trunc(-1.4) = -1 → 100 - 1 = 99 + // Awake hunger rate = -8.0/hr → sleeping = -8.0 * 0.2 = -1.6/hr + // trunc(-1.6) = -1 → 100 - 1 = 99 const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 }); expect(r.stats.hunger).toBe(99); }); it('1 hour: happiness does not decay (rate too small to truncate)', () => { - // Awake happiness rate = -4.0/hr → sleeping = -0.8/hr → trunc(-0.8) = 0 + // Awake happiness rate = -4.5/hr → sleeping = -0.9/hr → trunc(-0.9) = 0 const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 }); expect(r.stats.happiness).toBe(100); }); it('1 hour: hygiene decays only 20% of awake rate', () => { - // Awake hygiene rate = -5.0/hr → sleeping = -1.0/hr → trunc(-1.0) = -1 + // Awake hygiene rate = -6.0/hr → sleeping = -1.2/hr → trunc(-1.2) = -1 const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 }); expect(r.stats.hygiene).toBe(99); }); it('1 hour: health base does not decay when stats are healthy', () => { const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 }); - // After deltas: hunger=99, happiness=100, hygiene=99, energy=100 — all ≥ 80 + // After deltas: hunger=99, happiness=100, hygiene=99, energy=100 — all ≥ 76 // Base health = 0 (sleeping), regen = trunc(1.5) = 1. 100 + 1 → clamped to 100. expect(r.stats.health).toBe(100); }); it('30 minutes: stats barely change due to Math.trunc', () => { const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 1800 }); - // hunger trunc(-7*0.2*0.5) = trunc(-0.7) = 0 → 100 - // happiness trunc(-4*0.2*0.5) = trunc(-0.4) = 0 → 100 - // hygiene trunc(-5*0.2*0.5) = trunc(-0.5) = 0 → 100 + // hunger trunc(-8*0.2*0.5) = trunc(-0.8) = 0 → 100 + // happiness trunc(-4.5*0.2*0.5) = trunc(-0.45) = 0 → 100 + // hygiene trunc(-6*0.2*0.5) = trunc(-0.6) = 0 → 100 // energy trunc(40*0.5) = 20 → stays 100 expect(r.stats.hunger).toBe(100); expect(r.stats.happiness).toBe(100); @@ -83,20 +249,20 @@ describe('baby sleeping decay', () => { stats: { hunger: 20, happiness: 50, health: 50, hygiene: 20, energy: 50 }, elapsedSeconds: 3600, }); - // hunger after: 20 + trunc(-7*0.2*1) = 20-1 = 19 - // hygiene after: 20 + trunc(-5*0.2*1) = 20-1 = 19 - // happiness after: 50 + trunc(-4*0.2*1) = 50 - // energy after: 50 + trunc(40*1) = 90 + // hunger after: 20 + trunc(-8*0.2) = 20 + trunc(-1.6) = 19 + // hygiene after: 20 + trunc(-6*0.2) = 20 + trunc(-1.2) = 19 + // happiness after: 50 + trunc(-4.5*0.2) = 50 + trunc(-0.9) = 50 + // energy after: 50 + trunc(40) = 90 // // healthDelta = 0 (base sleeping) - // hunger 19 < 70: -0.75*0.25 = -0.1875 - // hunger 19 < 40: -1.25*0.25 = -0.3125 - // hygiene 19 < 70: -0.75*0.25 = -0.1875 - // hygiene 19 < 40: -1.25*0.25 = -0.3125 - // energy 90, not < 50 → 0 - // happiness 50, not < 50 → 0 - // total = -1.0, trunc(-1.0) = -1 → health = 50-1 = 49 - expect(r.stats.health).toBe(49); + // hunger 19 < 50: -0.5*0.25 = -0.125 + // hunger 19 < 25: -1.0*0.25 = -0.25 + // hygiene 19 < 50: -0.5*0.25 = -0.125 + // hygiene 19 < 25: -1.0*0.25 = -0.25 + // energy 90 ≥ 50 → 0 + // happiness 50 ≥ 50 → 0 + // total = -0.75, trunc(-0.75) = 0 → health stays 50 + expect(r.stats.health).toBe(50); expect(r.stats.hunger).toBe(19); expect(r.stats.hygiene).toBe(19); }); @@ -110,12 +276,12 @@ describe('adult sleeping decay', () => { expect(r.stats.energy).toBe(100); }); - it('1 hour from full stats: hunger/happiness/hygiene barely change', () => { - // hunger: trunc(-4.5*0.2) = trunc(-0.9) = 0 → 100 + it('1 hour from full stats: hunger decays slightly, happiness/hygiene unchanged', () => { + // hunger: trunc(-5.0*0.2) = trunc(-1.0) = -1 → 99 // happiness: trunc(-2.5*0.2) = trunc(-0.5) = 0 → 100 - // hygiene: trunc(-3.5*0.2) = trunc(-0.7) = 0 → 100 + // hygiene: trunc(-4.0*0.2) = trunc(-0.8) = 0 → 100 const r = decay({ stage: 'adult', state: 'sleeping', elapsedSeconds: 3600 }); - expect(r.stats.hunger).toBe(100); + expect(r.stats.hunger).toBe(99); expect(r.stats.happiness).toBe(100); expect(r.stats.hygiene).toBe(100); }); @@ -138,75 +304,22 @@ describe('adult sleeping decay', () => { stats: { hunger: 15, happiness: 15, health: 50, hygiene: 15, energy: 10 }, elapsedSeconds: 3600, }); - // hunger after: 15 + trunc(-4.5*0.2) = 15 + 0 = 15 - // happiness after: 15 + trunc(-2.5*0.2) = 15 + 0 = 15 - // hygiene after: 15 + trunc(-3.5*0.2) = 15 + 0 = 15 + // hunger after: 15 + trunc(-5*0.2) = 15 + trunc(-1.0) = 14 + // happiness after: 15 + trunc(-2.5*0.2) = 15 + trunc(-0.5) = 15 + // hygiene after: 15 + trunc(-4*0.2) = 15 + trunc(-0.8) = 15 // energy after: 10 + trunc(35) = 45 // // healthDelta = 0 (base sleeping) - // hunger 15 < 60: -0.5*0.25 = -0.125 - // hunger 15 < 30: -1.0*0.25 = -0.25 + // hunger 14 < 60: -0.5*0.25 = -0.125 + // hunger 14 < 30: -1.0*0.25 = -0.25 // hygiene 15 < 60: -0.5*0.25 = -0.125 // hygiene 15 < 30: -1.0*0.25 = -0.25 // energy 45 ≥ 40 → 0 // happiness 15 < 40: -0.4*0.25 = -0.1 // happiness 15 < 20: -0.8*0.25 = -0.2 - // total = -(0.125+0.25+0.125+0.25+0.1+0.2) = -1.05, trunc(-1.05) = -1 + // total = -1.05, trunc(-1.05) = -1 expect(r.stats.health).toBe(49); - }); -}); - -// ─── Awake decay unchanged ──────────────────────────────────────────────────── - -describe('awake decay is unchanged', () => { - it('baby awake 1 hour: full original decay rates', () => { - const r = decay({ stage: 'baby', state: 'active', elapsedSeconds: 3600 }); - // hunger: 100 + trunc(-7) = 93 - // happiness: 100 + trunc(-4) = 96 - // hygiene: 100 + trunc(-5) = 95 - // energy: 100 + trunc(-8) = 92 - expect(r.stats.hunger).toBe(93); - expect(r.stats.happiness).toBe(96); - expect(r.stats.hygiene).toBe(95); - expect(r.stats.energy).toBe(92); - }); - - it('adult awake 1 hour: full original decay rates', () => { - const r = decay({ stage: 'adult', state: 'active', elapsedSeconds: 3600 }); - // hunger: 100 + trunc(-4.5) = 96 (trunc rounds toward zero → -4) - // happiness: 100 + trunc(-2.5) = 98 (trunc → -2) - // hygiene: 100 + trunc(-3.5) = 97 (trunc → -3) - // energy: 100 + trunc(-5) = 95 - expect(r.stats.hunger).toBe(96); - expect(r.stats.happiness).toBe(98); - expect(r.stats.hygiene).toBe(97); - expect(r.stats.energy).toBe(95); - }); - - it('baby awake with low stats: full health penalties', () => { - const r = decay({ - stage: 'baby', - state: 'active', - stats: { hunger: 20, happiness: 50, health: 50, hygiene: 20, energy: 50 }, - elapsedSeconds: 3600, - }); - // hunger after: 20 + trunc(-7) = 13 - // hygiene after: 20 + trunc(-5) = 15 - // happiness after: 50 + trunc(-4) = 46 - // energy after: 50 + trunc(-8) = 42 - // - // healthDelta = -0.75 (base) - // hunger 13 < 70: -0.75 - // hunger 13 < 40: -1.25 - // hygiene 15 < 70: -0.75 - // hygiene 15 < 40: -1.25 - // energy 42 < 50: -0.5 - // happiness 46 < 50: -0.5 - // total = -(0.75+0.75+1.25+0.75+1.25+0.5+0.5) = -5.75 - // trunc(-5.75) = -5 → health = 50 - 5 = 45 - expect(r.stats.health).toBe(45); - expect(r.stats.hunger).toBe(13); - expect(r.stats.hygiene).toBe(15); + expect(r.stats.hunger).toBe(14); }); }); @@ -215,8 +328,8 @@ describe('awake decay is unchanged', () => { describe('hibernating is not treated as sleeping', () => { it('baby hibernating uses awake decay rates', () => { const r = decay({ stage: 'baby', state: 'hibernating', elapsedSeconds: 3600 }); - // Same as awake — energy uses awake rate (-8), not sleep regen - expect(r.stats.energy).toBe(92); - expect(r.stats.hunger).toBe(93); + // Same as awake — energy uses awake rate (-9), not sleep regen + expect(r.stats.energy).toBe(91); + expect(r.stats.hunger).toBe(92); }); }); diff --git a/src/blobbi/core/lib/blobbi-decay.ts b/src/blobbi/core/lib/blobbi-decay.ts index fdc447cf..d939f274 100644 --- a/src/blobbi/core/lib/blobbi-decay.ts +++ b/src/blobbi/core/lib/blobbi-decay.ts @@ -52,48 +52,62 @@ export interface DecayInput { /** * Baby stage decay rates (per hour). - * - * Design goal: Needs attention every 3-5 hours. + * + * Design goal: First stat (energy) drops to "okay" (3/4) around 2.7 hours, + * first "attention" (2/4) around 5-6 hours. Simpler than adult, needs care + * sooner but not punitively. + * + * Health penalty thresholds are aligned to baby segment boundaries: + * attention = value ≤ 50, urgent = value ≤ 25. + * Penalties only begin at "attention" — no silent health drain while UI + * still shows "okay". */ const BABY_DECAY = { - hunger: -7.0, - happiness: -4.0, - hygiene: -5.0, + hunger: -8.0, + happiness: -4.5, + hygiene: -6.0, energy: { - awake: -8.0, - sleeping: 6.0, // Regeneration + awake: -9.0, + sleeping: 6.0, // Legacy value — overridden by BABY_SLEEP_ENERGY_REGEN }, health: { - base: -0.75, - hungerBelow70: -0.75, - hungerBelow40: -1.25, - hygieneBelow70: -0.75, - hygieneBelow40: -1.25, + base: -0.4, + // Tier 1: mild — stat in attention range (≤ 50) + hungerBelow50: -0.5, + hygieneBelow50: -0.5, energyBelow50: -0.5, - energyBelow25: -1.0, happinessBelow50: -0.5, + // Tier 2: strong — stat in urgent range (≤ 25) + hungerBelow25: -1.0, + hygieneBelow25: -1.0, + energyBelow25: -1.0, happinessBelow25: -1.0, - // Regeneration when all stats are >= 80 - regenThreshold: 80, + // Regeneration when all stats are in "good" range (4/4 = value ≥ 76) + regenThreshold: 76, regenRate: 1.5, }, } as const; /** * Adult stage decay rates (per hour). - * - * Design goal: Needs attention every 5-7 hours. + * + * Design goal: First stat (energy) drops to "okay" (7/10) around 5-6 hours, + * first "attention" (6/10) around 7-8 hours. More resilient than baby — growing + * up should feel like a reward, not more annoyance. + * + * Adult penalty thresholds were already close to segment boundaries and + * are left unchanged. */ const ADULT_DECAY = { - hunger: -4.5, + hunger: -5.0, happiness: -2.5, - hygiene: -3.5, + hygiene: -4.0, energy: { - awake: -5.0, - sleeping: 5.0, // Regeneration + awake: -5.5, + sleeping: 5.0, // Legacy value — overridden by ADULT_SLEEP_ENERGY_REGEN }, health: { - base: -0.4, + base: -0.25, hungerBelow60: -0.5, hungerBelow30: -1.0, hygieneBelow60: -0.5, @@ -285,23 +299,23 @@ function calculateBabyDecay( // Base health decay is 0 while sleeping. let healthDelta = isSleeping ? 0 : BABY_DECAY.health.base * elapsedHours; - // Hunger penalties - if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * penaltyMul * elapsedHours; - if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * penaltyMul * elapsedHours; + // Hunger penalties (aligned to baby segment boundaries: attention ≤ 50, urgent ≤ 25) + if (hunger <= 50) healthDelta += BABY_DECAY.health.hungerBelow50 * penaltyMul * elapsedHours; + if (hunger <= 25) healthDelta += BABY_DECAY.health.hungerBelow25 * penaltyMul * elapsedHours; // Hygiene penalties - if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * penaltyMul * elapsedHours; - if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * penaltyMul * elapsedHours; + if (hygiene <= 50) healthDelta += BABY_DECAY.health.hygieneBelow50 * penaltyMul * elapsedHours; + if (hygiene <= 25) healthDelta += BABY_DECAY.health.hygieneBelow25 * penaltyMul * elapsedHours; // Energy penalties - if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * penaltyMul * elapsedHours; - if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * penaltyMul * elapsedHours; + if (energy <= 50) healthDelta += BABY_DECAY.health.energyBelow50 * penaltyMul * elapsedHours; + if (energy <= 25) healthDelta += BABY_DECAY.health.energyBelow25 * penaltyMul * elapsedHours; // Happiness penalties - if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * penaltyMul * elapsedHours; - if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * penaltyMul * elapsedHours; + if (happiness <= 50) healthDelta += BABY_DECAY.health.happinessBelow50 * penaltyMul * elapsedHours; + if (happiness <= 25) healthDelta += BABY_DECAY.health.happinessBelow25 * penaltyMul * elapsedHours; - // Health regeneration (all stats >= 80) + // Health regeneration (all stats in "good" range: 4/4 = value ≥ 76) const threshold = BABY_DECAY.health.regenThreshold; if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) { healthDelta += BABY_DECAY.health.regenRate * elapsedHours;