# BaoLife Polish-Plus — Progress Tracker

Machine truth: `../state.yaml`. Status: `[ ]` todo · `[~]` in progress · `[x]` done · `[!]` blocked.
_Last updated: 2026-05-27 (T008/W3 done)._ Baseline: tsc clean, vitest **1627** → now **1673**.

## Two named follow-ups
- [x] **FU-A** Diamond clamp — diamonds never go negative on unaffordable choices (T001 done; tsc clean, vitest 1636)
- [x] **FU-B** Offline-digest "you died" line — root cause = REAL gap (offline death half-applied, bypasses handleDeath, loop never stops, reconnect never routes to DeathView). FIXED (T003); tsc clean, vitest 1640

## Proceed further (discover → pick → execute high-leverage, player-felt waves)
- [x] **T004** Scout — stats are cosmetic; ranked candidates
- [x] **T005** Judge — split into waves: W1 deepen activities → W2 stats matter → W3 childhood → W4 reward verify (defer wealth)
- [x] **W1 (T006)** Deepen `performActivity`: persisted skill progression + varied outcomes — _done; tsc clean, vitest 1648_
- [x] **W2 (T007)** Make stats matter: stat-gated events + intelligence→promotion — _done; tsc clean, vitest 1665_
- [x] **W3 (T008)** Childhood (0-9) content pack — _done; tsc clean, vitest 1673_
- [ ] **W4 (T009)** Verify reward beat reaches the player in real play

## Constraints
- ⛔ DO NOT touch avatar WIP (avatar.ts, avatarLibrary.*, Player.ts DiceBear-migration) — separate uncommitted effort.
- Mirror loop changes online+offline · tsc clean + vitest ≥1627 after each change · iOS priority/Android parity · no deploy unless asked.

## Final
- [x] ✅ GOAL COMPLETE (T999 PM audit, `full_outcome_complete: true`) — 2 follow-ups + 4 player-felt waves; tsc clean, vitest 1627→1678. UNCOMMITTED (recommend commit/deploy). Deferred (not churn): wealth/economy curve pending raise-curve check.

### Run log
- T009 Worker 🔄 active — W4 verify reward beat reaches the player (online-path integration test)
- T008 Worker ✅ done — W3 childhood (0-9) content pack. The youngest pre-existing v2 event was `minAge: 5`, so a new player's opening years (the retention-critical window) were content-thin. Added NEW catalog `events/v2/catalog/childhood.ts` (registered in `catalog/index.ts`, spread right after dilemmas) — **10 warm, age-appropriate early-life events** spanning ages ~2-9, all namespaced `childhood.*` and all `category: 'family'` (reused an existing EventCategory bucket — no new type). **Reachable BEFORE age 5 (minAge < 5):** `childhood.firstWords` (2-3, passive), `childhood.firstSteps` (2-4, passive), `childhood.shareTheToy` (3-6, INTERACTIVE: share→social+4/happy+3 vs keep→-1/-1), `childhood.bedtimeStory` (2-8, passive + REPEATABLE cooldown 120d, date-seeded promptFn+resolutionTextFn so recurrences vary). **Ages 5-9 (maxAge-capped to stay in childhood):** `childhood.firstDayOfSchool` (5-7, passive, intel+3/social+2/happy+2), `childhood.learnToRideBike` (5-9, INTERACTIVE: brave it vs go slow), `childhood.firstPet` (5-9, INTERACTIVE: care-for-it→intel+2 vs just-play), `childhood.familyTrip` (6-9, passive, happy+5), `childhood.lostTooth` (6-8, passive, happy+4), `childhood.scarySlide` (5-9, INTERACTIVE: brave vs small slide). **6 passive milestones + 4 interactive** beats. All stat deltas are small (|Δ|<=5) and only touch the Person roster (intelligence/social/creativity/happiness/health/stress — NO `looks`). Only the genuinely-recurring bedtime story is repeatable; every other milestone is once-ever. Nothing is gated behind an unreachable condition — each fires for a plain young character with no flags/relations (a test asserts every beat is eligible at its window midpoint). Exported `resolveChildhoodChoice(eventId, choiceId)` (static-text resolver, mirrors `resolveLateLifeChoice`). Tests (NEW, no existing test changed): `tests/events-v2/catalog.childhood.test.ts` (8 cases) — registered in global catalog + globally unique ids (builds the EventRegistry, which throws on collision); >=8 unique namespaced ids; well-formed choices (resolutionText round-trips via resolver, only valid stats, |Δ|<=5); passive vs interactive split; at least one minAge < 5 + toddler eligibility at age 2; 6yo eligible for the core beats; adult (30) gated out by maxAge; bedtime story repeatable-on-cooldown; no unreachable gates. Verify: tsc exit 0; vitest **1673** passed (140 files, +8 new). Avatar WIP untouched.
- T007 Worker ✅ done — W2 make stats matter. Stats were write-only (no v2 `isEligible` read intelligence/creativity/social; promotion was random+focus and read no stat). Now the trajectories W1 builds pay off. **(1) Intelligence → promotion** (`services/jobs/job_manager.ts`): `handleJob` gained an optional injectable `rng = Math.random` (deterministic tests; all existing 2-arg callers + the offline `GameEngine` private wrapper unaffected). TWO levers, both documented: (a) **PERF BONUS** — at `intelligence >= INTELLIGENCE_PERF_BONUS_THRESHOLD (60)` the per-tick performance roll range shifts up +1 on both ends, so a sharp worker climbs toward the >90 ceiling faster/more reliably (a bias, never a guarantee — a slacking smart worker can still stall); (b) **TOP-TIER FLOOR** — the SINGLE top rung of an elite (already prestige-gated `PRESTIGE_GATE_TOP`) ladder also requires `INTELLIGENCE_GATE_TOP (75)`; held just below the ceiling (not fired) with a one-time explainer message, exactly like the prestige gate. Existing education + prestige gates left intact. **(2) Stat-gated payoff events**: `career.flagshipProject` (NEW, repeatable career payoff, gated `intelligence >= 60`, outsized money+prestige reward); `random_creative_breakthrough` (NEW, gated `creativity >= 60`, monetize-your-creativity, real income); `random_networking_opportunity` (NEW, gated `social >= 60`, doors-open-through-people). All three are cadence+cooldown repeatable passives/career beats with low/normal weight. **No engine change needed** — `isEligible` already receives the full player; new shared `stat(player,key)` reader in each catalog (default 50). **(3) Milestone safety:** every gate guards BONUS content only — none touches graduate/marry/have-a-child or any ordinary career (ordinary ladders verified ungated by a regression test). Files: `services/jobs/job_manager.ts`, `events/v2/catalog/career.ts`, `events/v2/catalog/random.ts`. Tests (NEW, no existing test changed): `tests/events-v2/catalog.stat-gates.test.ts` (gate FLIPS at threshold for all 3 events + employment/cadence guards + reward shape); `tests/services/jobs/jobs.intelligencePromotion.test.ts` (perf bonus delta = +1 same-RNG; smart promotes where dull stalls on the same low roll; top-tier floor blocks below / allows at `INTELLIGENCE_GATE_TOP`; ordinary career NOT gated). Verify: tsc exit 0; vitest **1665** passed (139 files, +17 new).
- T006 Worker ✅ done — W1 deepen `performActivity` (skill progression + varied outcomes). Converted the flat-fixed-delta agency loop into a real progression + outcome loop. (1) **Persisted skill model:** added `skills: Record<string, number>` to `Person` (interface + class, `data.skills ?? {}` in ctor, included in `toJSON()`) — keyed by activity id (`skills.study` etc). Player.ts untouched (Person.ts is clean). (2) **Skill-scaled gains + progression** (`intradayActivity.ts`, new single-source-of-truth helpers `resolveActivityOutcome`/`skillMasteryMultiplier`/`rollOutcomeTier`/`skillTierLabel`/`activityOutcomeMessage`): FRONT-LOADED diminishing-returns mastery tiers — novice(0-4)=1.6x, apprentice(5-14)=1.3x, skilled(15-29)=1.15x, expert(30+)=1.0x. So study no longer gives a flat +3 forever: skill 0 → round(3*1.6)=5, tapering to +3 at expert, while the skill itself grows +1 per session. Gains scaled then rounded (direction preserved: a non-zero base never rounds to 0). (3) **Varied outcomes** via SEEDED INJECTABLE RNG (handler + helpers take optional `rng = Math.random`, matching the v2 selector pattern): tiers poor(<0.15, 0.5x)/normal(0.15-0.85, 1.0x)/great(>=0.85, 1.75x + bonus skill). `activityPerformed` payload now carries `outcomeTier`, `skillLevel` (new level), `skillTier` (novice..expert), `deltas` (actual scaled deltas), and a tier-flavored `message`; `effects` (base) kept for back-compat. (4) **Gates preserved:** flat energy cost (predictable gate), age gate, free-slot/daily-plan logic, override mode, `clampPlayerStats` all unchanged; no stat GATES added (W2). Rejected paths do NOT credit skill. `CommandHandler` type unaffected (optional 3rd param is assignable; dispatcher calls with 2 args). Files: `models/Person.ts`, `game/engine/intradayActivity.ts`, `handlers/performActivity.ts`. Tests: new `tests/handlers/performActivity.progression.test.ts` (8 cases — skill increment, toJSON round-trip persistence, non-flat 18-session trajectory, deterministic poor/normal/great tiers, energy/age/free-slot gates don't mutate skills, pure `resolveActivityOutcome`); updated 2 assertions in `tests/handlers/performActivity.test.ts` (the study `+3` and side-hustle `+40` assertions legitimately changed — old flat deltas; now assert skill-scaled 5/64 with a seeded rng). Verify: tsc exit 0; vitest 1648 passed (137 files).
- T005 Judge ✅ done — split waves; W1 activities first (foundation), then W2 stat gates
- T004 Scout ✅ done — TOP FINDING: stats are cosmetic (write-only; nothing gates on them). Ranked candidates: #1 make stats matter, #2 deepen activities, #3 late-game wealth(defer), #4 childhood content, #5 verify reward delivery. AI backlog fixed. BOTH FOLLOW-UPS DONE.
- T003 Worker ✅ done — FU-B fix (offline death now mirrors the online canonical path). 3 changes: (1) `LoopManager.ts` daily death check (~:402) now calls shared `handleDeath(player)` instead of the inline `player.c.status='dead'` — sets dead/inactive, queues "You have died!" (deduped), builds + persists `player.lifeSummary`; achievement check keeps the pre-death age/money. (2) `processOfflineTime` per-minute loop (~:710) now `break`s the moment `player.c.status==='dead'` so age/finance freeze at the death point (no ticking to gap end); `applyProratedFinances` stays alive-guarded → no-ops once dead. (3) `WebSocketServer.ts` reconnect (~:262) detects a freshly-dead-offline player after `processOfflineTime`, persists the dead state, and after `sendPlayerObject()` emits `lifeSummaryEvent` (shape mirrors `PlayerSession.processDayTick`) so the client routes to DeathView. `handleDeath` itself untouched. New test `tests/game/offlineDeath.consistency.test.ts` (4 cases): forced offline death mirrors online handleDeath state; loop stops (age/money frozen, not advanced to gap end); reconnect emits lifeSummaryEvent + playerObject carries dead status; control no-death run stays alive with no spurious death line. Note: offline loop drains the queued death message into `messageLog` (expected) and the offline tick path always flips controller→inactive (pre-existing, unrelated to death). Verify: tsc exit 0; vitest 1640 passed (136 files).
- T002 Scout ✅ done — root cause = (A) real gap; offline death bypasses handleDeath/never stops loop/never routes
- T001 Worker ✅ done — diamond clamp (never-negative). Root cause: `events/v2/engine/effects.ts` `applyEventEffects` applied `resources.diamonds` deltas directly to `c.diamonds` with NO affordability gate / NO clamp. `respond.ts:applyChoiceEffects` feeds `-choice.diamondCost` into it, so unaffordable choices drove the balance negative. Fix at the chokepoint (`effects.ts`): an unaffordable negative diamond delta (a spend) no-ops the diamond portion (non-diamond effects still apply); all diamond mutations clamp at >= 0; positive deltas (grants/rewards) always apply. `diamondEconomy.ts` (`spendDiamonds`/`writeBalance`) already enforced never-negative + affordability and was left untouched, as was the `awardDiamonds`/IAP path. New test: `tests/events-v2/engine.diamond-clamp.test.ts` (9 cases) — unaffordable no-op, affordable deduct, grant, exact-to-0 clamp, full EventResponder via `diamondCost`, plus `awardDiamonds`/`spendDiamonds` IAP path unaffected. Verify: tsc exit 0; vitest 1636 passed (135 files).
