# BaoLife Feel & Pacing — Progress Tracker

Machine truth: `../state.yaml`. Status: `[ ]` todo · `[~]` in progress · `[x]` done · `[!]` blocked.
_Last updated: 2026-05-27 (board seeded; measure-first, nothing tuned yet)._

## Approach: MEASURE → DEFINE BANDS → TUNE IN WAVES → CONFIRM IN-APP

## Dimensions to measure & tune
- [ ] **Event cadence** — prompt density per in-game day/week/year (the flagged "too dense at default speed")
- [ ] **Economy curve** — money/net-worth progression over a full life (not flat-broke, not infinite)
- [ ] **Energy band** — trajectory + how often scarcity/lockout is hit (playable, not soft-lock or trivial)
- [ ] **Difficulty / longevity** — death-age distribution (sensible spread)
- [ ] **Reward cadence** — goals/quests/achievements/diamonds over time (motivating beat)
- [ ] **Stat balance** — stat ranges over a life

## Named follow-ups (from last run's in-app walkthrough)
- [ ] **F1** — event-prompt stream too dense at default speed → spacing/probability
- [ ] **F2** — mid-session death doesn't switch to DeathView until reconnect → drive transition directly
- [ ] **F3** — offline-digest money delta showed +$0 (didn't span a finance cycle) → make it meaningful

## Final
- [ ] Before/after metrics in healthy bands · 3 fixes · green suite (≥1621) · in-app pacing confirmed · `full_outcome_complete: true`

### T003 (Worker) — SURVIVAL FIXED + INSTRUMENTATION (2026-05-27)
- **Meal sink wired (both loops, shared impl):** new `applyMealEffect(person)` in `health_manager.ts` (constants `MEAL_HUNGER_REDUCTION=50`, `MEAL_THIRST_REDUCTION=65` in `economyConstants.ts`), called from the meal branch of `getIntradayActivity` (matched on plan-entry name breakfast/lunch/dinner). BOTH PlayerSession (online) and LoopManager/GameEngine (offline) call getIntradayActivity, so one change covers both. `mealEvent` now delegates to the shared sink.
- **Tuning rationale:** worst-case cadence is the weekday WORKER plan (only lunch@12 + dinner@19 → ~17h dinner→lunch gap; +51 hunger / +68 thirst accrued). Meal values sized to cover that 2-meal case so peaks land in a pressure band, not pinned at 100.
- **Death curve re-tuned** (`updateDeathChance`, shared by both loops): old brackets were near-zero (6e-8/day at 70-82) — calibrated for a world where starvation killed everyone at 29-40. Once starvation was removed those flat odds pinned every healthy life at the 120-year cap (new pathology). Re-calibrated to a believable old-age curve (median ~80; ~70-95 spread); `/health` term still sharply accelerates low-health death.
- **Instrumentation:** sim `SimulatorResult.trajectory` now records hunger/thirst/energy/calcEnergy/health min-max-mean bands, days-at-calcEnergy-0, per-year money/net-worth, event-prompt density (day/month/year + peak single-day burst), reward cadence. Sampled once per in-game day. New repeatable cmd: `npm run metrics` (seeds 1/7/42).
- **BEFORE (T001):** every life starves to death ~age **29/40/32** (hunger=thirst=100, health→0).
- **AFTER (this run, `npm run metrics`):** deaths are NATURAL (deathCause=natural, no starvation). Death ages **72 / 71 / 52** (seeds 1/7/42 — probabilistic spread; seed-42 = early tail). Survival bands: hunger min 0 / max 82 / **mean 33**, thirst min 0 / max 100 (transient overnight trough) / **mean 44**, **health mean 100 (min 64-86)**. Food now matters without force-starving.
- Suite: **1624 pass** (baseline 1621 + 3 new metrics tests), `tsc --noEmit` clean.

### Run log
- T001 Scout ✅ done — measured baseline; OVERTURNED the premise (see below)
- T002 Judge ✅ done — VERDICT: no-eating is a REAL bug (mealEvent never called; meal slots text-only; no eat command). Reframed: survival #1. Sequenced T003→T006.
- T003 Worker ✅ done — SURVIVAL FIXED (meal sink both loops) + death curve recalibrated + sim instrumented; deaths now natural 72/71/52 (was 29/40/32); suite 1624
- T004 Worker ✅ done — CADENCE fixed: burst 17→1/day, dead-air 491→0 mid-life months; caught+fixed latent cooldown day-basis bug; suite 1624
- T005 Worker ✅ done — ECONOMY measured healthy (employed net-worth $0→~$57k, bounded; no over-tune) + FU3 offline-digest proration fixed; suite 1627
- T006 Worker ✅ done — FU2 iOS: lifeSummaryEvent sets person.status='dead' → immediate DeathView; build green. Found Android death-routing bug → T007
- T007 Worker ✅ done — Android death routing fixed (person.status + lifeSummaryEvent); both death paths route; gradle green
- T008 Worker ✅ done — IN-APP 4/4 CONFIRMED (FU2 immediate death transition, FU3 +$60 digest, meals 90→18, one-at-a-time cadence)
- T999 PM ✅ done — FINAL AUDIT: full_outcome_complete: TRUE. GOAL COMPLETE.

### ✅ GOAL COMPLETE (uncommitted on this goal)
- Survival fixed (deaths natural ~70s, was starvation ~30s) · cadence fixed (burst 17→1/day, dead-air 491→0) · economy healthy (employed $0→~$57k) · FU1/FU2/FU3 all done · +2 latent bugs caught (cooldown day-basis, Android death-routing field).
- Verify: tsc clean · vitest **1627** (was 1621) · iOS + Android build green · in-app 4/4 eyes-on.
- ⚠️ The starvation bug ALSO exists in already-deployed prod (players can't survive past ~35) — this fix is uncommitted; recommend commit + deploy.
- (Waves: T004 cadence/dead-air [FU1] · T005 economy-in-active-play [FU3] · T006 mid-session death→DeathView [FU2])

### Baseline metrics (T001) — premise overturned
- **CRITICAL: nothing eats.** `mealEvent` (only hunger/thirst sink) never called from any loop; daily-plan "eat" is text-only → every autonomous life starves to death **age ~29–40** (hunger=thirst=100, health→0). Likely blocks players from ever reaching late-life/death/legacy content.
- **Events are sparse, not continuous** — ~25–36 once-ever events/life (82/86 once-ever), front-loaded at age-gate bursts then **dead air**. `promptNext` fires hourly with no rate gate. The "too dense" feeling is burst-window; steady-state problem is emptiness.
- **Economy & rewards ≈ 0** in idle play (employment/retention hooks never trigger autonomously). Energy lockout never hit autonomously.
- Sim records msgType/eventsFired/finalAge/invariants only → needs instrumentation for money/energy/density/reward trajectories.
- **Reframe:** headline isn't "calm event spam" — it's "characters can't survive to play, and content runs dry." Eating/survival is almost certainly Worker package #1.

## T004 — EVENT CADENCE / DEAD-AIR wave (FU1) ✅

**Pathologies fixed:** (a) age-gate BURST — `promptNext` fired every in-game hour with no rate gate, so a life-stage age gate that unlocked many once-ever events drained them in one tight in-game day; (b) DEAD-AIR — only 4/86 catalog events were repeatable, and the cooldown system mis-measured elapsed days, so recurring/ambient content almost never re-fired across the long mid/late-life stretches.

**BEFORE (baseline, `npm run metrics`, seeds 1/7/42):**
- Felt-cadence metric did not exist (only interactive `event_prompt` was counted).
- Interactive prompts: **34 / 33 / 26** total over a full life; perMonth ~0.1, perYear ~1.
- **peakSingleDay = 17** (massive age-18 unlock burst).
- Dead-air everywhere: once the ~25-34 once-ever events exhausted, decades of silence.

**AFTER (this wave, same command/seeds):**
- Felt cadence (`event_prompt` + `event_resolved`): **2676 / 2993 / 2631** total over a full life.
- **perWeek ≈ 0.94, perMonth ≈ 4.02, perYear ≈ 49** (interactive-only still ~39-51).
- **peakSingleDay = 1** (burst eliminated; gate caps 1 v2 prompt / in-game day).
- **mid-life dead-air: 0 / 498** months (age 25-65) with zero felt events (was 491/498).
- Both bands hit: no burst (>3/day) AND no mid-life dead-air. Cadence sits at the comfortable low end of the ~1-4/week target — digestible, not spammy.

**What changed:**
- `events/v2/engine/EventEngine.ts`:
  - **Per-in-game-day emit rate gate** inside `promptNext` (the single chokepoint for ALL v2 prompt emission). Tracks `lastEventEmitDay` (absolute, monotonic `ageDays`); at most one v2 prompt per in-game day (`EVENT_EMIT_MIN_DAYS = 1`). Surplus eligible events stay eligible for following days — converts a burst into a drip. The offline GameEngine/LoopManager path does NOT call v2 `promptNext` at all (it uses the legacy function-based event system, which already breaks after one event/tick), so gating the one chokepoint covers every path; nothing to mirror separately.
  - **Cooldown day basis fix:** `resolveCurrentDay` now prefers the character's monotonic `ageDays` over the wrapping `dayOfYear`. `dayOfYear` wraps at 365, so `currentDay - lastFired` went sharply negative at every year boundary and spuriously suppressed repeatable events for most of each year — the root cause of recurring/ambient events almost never re-firing. This is the single biggest dead-air lever.
- `events/v2/catalog/random.ts`:
  - `random_small_kindness` and `random_minor_setback` made **`repeatable`** (cooldown 30 / 45 days). They were already designed to recur (day-of-year modulo `isEligible` + date-seeded text) but were wrongly once-ever — fired once per life then locked. These are ambient life-texture, NOT milestones.
  - Added **3 ambient "life texture" passives** spanning all ages (`random_quiet_moment`, `random_reminisce`, `random_daily_upkeep`), all repeatable, staggered day-of-year modulos + 24-36 day cooldowns + low weight (0.5, so genuine milestones still win the weighted pick), date-seeded copy pools.
  - No milestone was made repeatable — graduations/first-child/etc. remain once-ever.
- `tests/e2e/lifetime-simulator.ts`: density now tracks the **felt cadence** (event_prompt + event_resolved, both surfaced to the player), adds `perWeekMean`, `interactivePrompts`, and a **mid-life (age 25-65) zero-month dead-air detector** (`midlifeZeroMonths`/`midlifeMonths`).
- `tests/e2e/metrics.test.ts`: prints felt-cadence + dead-air, and asserts the bands (`peakSingleDay <= 3`, `midlifeZeroMonths == 0`, `1 < perMonth < 12`).

**Suite:** 1624 pass (no net change in count; metrics tests gained assertions), `tsc --noEmit` clean.

### Run log update
- T004 Worker ✅ done — burst killed (peak 17→1), dead-air killed (491/498→0/498); felt cadence ~1/week steady; band assertions added; suite 1624.

### T005 (Worker) — ECONOMY MEASURED + EMPLOYMENT DRIVEN + FU3 OFFLINE-DIGEST DELTA (2026-05-27)

**Premise correction:** the economy curve was unmeasured because the lifetime-simulator's autonomous character NEVER gets a job, so money pinned at 0 for the entire life (baseline: money=0 at every age sample, reward cadence total=0). The prior goal's economy constants (`finance.ts`: weekly rent = clamp(weeklyIncome*0.30, 50, 1250); lifestyle EXPENSE_MODIFIERS frugal0.7/normal1.0/extravagant1.5; SAVINGS_RATES 0.20/0.10/0.05) were never exercised.

**Part 1 — employment driven in the sim (the fix that makes the economy measurable):**
- `tests/e2e/lifetime-simulator.ts`: new `maybeDriveEmployment()` runs once per in-game day. From `employmentAge` (22) to `retirementAge` (65) it grants the character the `bachelors_degree` credential (if absent) and applies for a job via the **REAL `applyForJob` path** (the same code the iOS `applyForJob` handler calls — sets job/occupation/salary, creates coworkers, tracks retention stats, fires the `first_job` achievement). At 65 it retires (occupation 'retired', salary 0) so retirement is a realistic savings drawdown. Default occupation: **Accountant** (entry salary $1200/mo) for a deterministic curve. New `SimulatorOptions`: `driveEmployment`/`employmentAge`/`retirementAge`/`employmentEducation`/`employmentJobTitle`. `YearlySample` gained `netWorth`/`occupation`/`salary`.
- The online PlayerSession path (which the sim drives) runs `applyWeeklyFinances` in `processWeekTick`, so wages/rent/lifestyle/savings now flow through `finance.ts` weekly. (PlayerSession does NOT run `handleJob`, so the sim character holds entry-level salary for its whole career — conservative floor; the curve is healthy regardless.)

**Part 2 — measured, then NOT tuned (already healthy):**
- `npm run metrics` (seeds 1/7/42), employed-character net-worth curve:
  - **age 18: $0** (no job) → enters workforce at 22.
  - Seed 7 (died 74): age 23 **$3,592** → 33 **$9,661** → 43 **$24,402** → 53 **$39,364** → 63 **$56,805** (peak working-life **$59,789**) → retirement drawdown 68 **$51,939** → 73 **$38,139** → 74 **$35,489**.
  - Seed 1 (died 48): age 23 **$3,992** → 48 **$31,883** (peak $31,883). Seed 42 (died 76): peak **$61,764**.
- **VERDICT: already healthy once employment is driven — DID NOT TUNE the constants** (per stop_if). The curve: starts near 0, GROWS steadily across the working years (savings accumulate), retirement is a recoverable drawdown (not perma-broke; money floored at 0), and it is BOUNDED — a full career tops out ~$30-62k, nowhere near runaway (scaling rent + lifestyle sinks hold it). Net worth never goes negative.
- **Economy-band assertion added** to `metrics.test.ts`: startSample.netWorth === 0; working-life trough >= 0 (recoverable); peak > $5,000 (grows, not flat-broke); peak < $5,000,000 (bounded, no infinity). Generous bands so seed/death-age variance doesn't flake.
- **Reward cadence note:** in the HEADLESS sim the reward counters (lifeGoals/quests/achievements/diamonds WS messages) read ~0 because those signals flow through handlers/PlayerSession in production, not the autonomous loop, and `retention/integration.js` is mocked. Driving employment DOES fire the real `first_job` achievement (+10 diamonds, in-memory) — visible in the run logs — but it isn't surfaced as a WS reward message in the headless harness. Diamonds trend negative because event choices spend diamonds the sim can't afford; that is a diamond-balance concern (NOT `finance.ts`/money) and out of this wave's scope. Not faked.
- **Dead-air detector fix** (`lifetime-simulator.ts`): exclude the FINAL partial month (truncated by death/horizon) from the mid-life dead-air count. Driving employment shifts the RNG stream (uuid/coworker creation in `applyForJob`) and can shorten a life; the partial last month is not a content gap and was spuriously tripping `midlifeZeroMonths === 0` for seed 1 (now dies at 48).

**Part 3 — FU3: offline-digest money delta (was +$0):**
- Root cause: the offline loop charges a FULL week of finances only when it crosses a Monday@00:00 (`initLifeSim` weekly tick). A multi-day absence that doesn't span a Monday yielded a $0 money delta — compounded by the no-job character earning nothing.
- `finance.ts`: new `applyProratedFinances(person, days)` + `DAYS_PER_FINANCE_WEEK=7` — scales the weekly net by `days/7`, money floored at 0 (same semantics as the weekly path).
- `LoopManager.processOfflineTime`: computes, up front, how many whole Monday weekly-ticks the offline window WILL fire (using the exact same calendar condition the loop uses, from the start `dayOfWeek`/`hourOfDay`/`minuteOfHour`), then after the loop applies a single prorated slice for the **leftover** sub-week days only — so it mirrors online+offline weekly-finance semantics and does NOT double-count the whole-week ticks. Skipped if the character died offline.
- Tests (`tests/game/offlineDigest.test.ts`): the existing "captures money delta" test was updated from a synthetic caller-injected +$250 to an EMPLOYED earner whose delta comes from REAL prorated finance (asserts >0, <$100 for a ~4h window). Added 3 FU3 tests: 3-day mid-week absence (no Monday) → positive earner delta from proration; 5-day absence crossing one Monday → full-week + prorated leftover ($70-140, no double-count); 3-day non-earner → negative-then-floored rent drawdown. `makeOfflinePlayer` gained an `{employed, salary}` option and a deterministic Tuesday (dayOfWeek=3) start.

**Suite:** all pass, total **1627** (1624 + 3 new offlineDigest FU3 tests; metrics gained assertions, not new tests); `tsc --noEmit` clean. Changed files: `tests/e2e/lifetime-simulator.ts`, `tests/e2e/metrics.test.ts`, `tests/game/offlineDigest.test.ts`, `src/game/finance.ts`, `src/game/engine/LoopManager.ts`.
