Tune awake decay rates for segment-aligned status model

Baby (4 segments):
- hunger -8/hr, happiness -4.5/hr, hygiene -6/hr, energy -9/hr
- health base -0.4/hr (was -0.75)
- health penalty thresholds aligned to segment boundaries:
  mild at < 50 (attention), strong at < 25 (urgent)
  — was < 70/40, which fired penalties in the 'okay' range
- health regen threshold lowered to 76 (baby good = 4/4 starts at 76)
  — was 80

Adult (10 segments):
- hunger -5/hr, happiness -2.5/hr, hygiene -4/hr, energy -5.5/hr
- health base -0.25/hr (was -0.4)
- penalty thresholds unchanged (already align with 10-segment model)
- regen threshold unchanged at 80

Pacing:
- Baby first 'okay' stat at ~2.7hr, first 'attention' at ~5-6hr
- Adult first 'okay' stat at ~5-6hr, first 'attention' at ~7-8hr
- Growing up feels like increased resilience, not more annoyance

Sleep behaviour, item values, careState mapping, segmented UI
rendering, and Nostr persistence are unchanged. Tests updated to
cover new rates, penalty alignment, and regen threshold.
This commit is contained in:
filemon
2026-04-24 13:52:11 -03:00
parent 63143db9db
commit 4d4d8a43e0
2 changed files with 246 additions and 119 deletions
+200 -87
View File
@@ -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) = 0100
it('1 hour from full stats: hunger decays slightly, happiness/hygiene unchanged', () => {
// hunger: trunc(-5.0*0.2) = trunc(-1.0) = -199
// 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);
});
});
+46 -32
View File
@@ -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;