# BaoLife — Late-Game Wealth / Economy Curve — PROGRESS

**Goal:** Make money meaningful across a whole life. **Measure first** (real income/expense/net-worth curve with career progression), **then** tune the late-game economy only if the data shows it's broken.

**Status:** ✅ DONE + SHIPPED — `full_outcome_complete: true`. Committed `db3dd13b`, pushed, auto-deployed to lichun-master and **verified live** (HEAD db3dd13b, service active, NRestarts=0, listener on 8001). Craig approved commit+deploy.

**Baseline:** HEAD `5add99d9` (polish-plus, deployed). Tests green: 1678/1678 (141 files). tsc clean.

---

## Why this slice

Carried over from `baolife-polish-plus`, which explicitly **deferred** the wealth/economy curve:
> "Wealth/late-game economy (#3): deferred pending a real promotion/raise-curve check (sim only holds entry job, understates income) — avoided rescaling on bad data."

So step 1 is to make the headless simulator actually progress careers, measure the real curve, and **only then** decide on tuning. Rescaling on entry-job-only data is the forbidden misfire.

## Hard constraints (carry-over)

- Measure before tuning. No economy rescaling until a real career-progression curve exists.
- Do NOT touch the avatar WIP (`avatar.ts`, `avatarLibrary.*`, `Player.ts` DiceBear change, ios `MainCharacterView.swift` / `QuickStatsCard.swift`).
- Never edit legacy `ws/`. Mirror loop/economy changes online + offline.
- Diamonds/IAP and the reward economy must not be corrupted by soft-currency tuning.
- After every change: `cd server && npx tsc --noEmit` then `npx vitest run` — green, >= 1678.
- iOS priority; Android parity for any economy-facing UI.
- No production deploy unless Craig asks.

---

## Task log

| Task | Type | Status | Summary |
|------|------|--------|---------|
| T001 | Scout | done | Economy mapped; **premise stale** — sim already drives employment + promotions exist |
| T002 | PM | done | Ran metrics → **real curve**: salary frozen $1200/mo whole career, peak ~$97.8k; no double-count |
| T003 | Worker | done | Engaged curve == passive (workFocus inert). **Root cause: career progression is dead code** |
| T004 | Judge | done | Root cause CONFIRMED. Ruled **Approach B**: wire `handleJob` into both weekly ticks |
| T005 | Worker | done | **Fix shipped + PM-verified** — careers climb, curve healthy & bounded, suite green |
| T006 | Worker | done | Client parity = verified **no-op** (iOS already shows salary/tier + renders promotions) |
| T999 | Judge | done | ✅ COMPLETE — full_outcome_complete: true (independently re-verified) |

## ✅ FINAL — goal complete (uncommitted, awaiting deploy decision)

**What shipped (working tree):** Career progression is no longer dead code. The tier-promotion `job_manager.handleJob` is now wired into both weekly ticks (online + offline, mirrored), so an engaged worker climbs the salary ladder and late-game money is meaningful and bounded. One latent bug fixed (promote-then-fire sawtooth: post-promotion performance 0→50).

**Verified:** tsc clean; full suite 142 files / 1679 tests / 0 failed (PM + final-audit Judge each re-ran). Engaged Accountant $163k / engaged SWE $274k vs passive ~$98k; all < $5M ceiling. No avatar WIP, `ws/`, finance/tier/stat constants, or IAP/reward code touched. Client = no-op (iOS already shows salary/tier + renders promotions).

**Owner action (non-blocking):** NOT committed/deployed by design (live-economy change). Recommend Craig verify in-app (drive a promotion/raise) + sign off before commit + push (webhook auto-deploys). Optional deferred: a prominent home-screen salary readout (overlaps avatar WIP — Craig's call).

**Changed files (working tree):** `server/src/game/PlayerSession.ts`, `server/src/game/engine/GameEngine.ts`, `server/src/services/jobs/job_manager.ts`, `server/tests/e2e/economy-curve.test.ts`, `server/tests/e2e/lifetime-simulator.ts` (+ T003), notes.

## T005 result — career progression restored (PM-verified)

Wired `job_manager.handleJob` into both weekly ticks (online + offline, mirrored, before finances) + fixed a latent promote-then-fire sawtooth (post-promotion performance 0→50). **Independently re-verified by PM:** `tsc` clean; full suite **142 files / 1679 tests / 0 failed / 0 skipped**.

**Re-measured curve (was: flat $1200 / ~$98k for everyone):**
| Trajectory | Salary ladder | Peak net worth |
|-----------|---------------|----------------|
| Engaged Accountant | $1200 → $2000 → $3000 (CFO) | **$163k** |
| Engaged Software Engineer | $2000 → $4000 → $6000 (Mgr; CTO gated) | **$274k** |
| Passive (control) | $1200 (late creep $1600) | $98k |

Engaged = 1.67×–2.8× passive; max $274k ≪ $5M ceiling. Late-game money is now **meaningful and bounded**. Full numbers: `notes/T005-wired-curve.md`.

**Not deployed** (per charter + Judge steer — needs Craig sign-off + in-app walkthrough).

## Judge ruling (T004) — Approach B

Wire the already-tested `job_manager.handleJob` (perf-advance from focus → gated tier promotion → `salary = newLevel.salary` → firing) into **both** weekly ticks, **before** finances, mirrored online (`PlayerSession.processWeekTick`) + offline (`GameEngine.handleWeeklyUpdates`, swapping the happiness-only private `handleJob`). Self-contained (handleJob advances `performance` from `focus` itself); `net = surplus × 0.10` savings governor caps runaway. **Hard stop:** no career/seed may peak past $5M. **Owner steer:** verify in-app + Craig sign-off before any deploy (this run won't deploy).

## 🔑 ROOT CAUSE (verified by PM, resolving a Scout/Worker contradiction)

**Recurring salary is frozen at the entry value for the entire career — career progression is inert in production.** Three would-be raise paths, all dead/declawed:
1. `job_manager.handleJob` (tier promotion → `salary = newLevel.salary`) is **dead code** — never called in any tick. Online `PlayerSession.processWeekTick:499` runs *only* `applyWeeklyFinances`; offline `GameEngine.handleJob:879` is a different method that only nudges happiness.
2. The **live v2 career catalog** (`events/v2/catalog/career.ts`) was ported to give **one-off `money` bonuses ($400/$600/$850), not salary raises** ("the v2 context doesn't track the salary field").
3. Legacy `careerMilestones.ts` (which *did* compound `c.salary`) is the old class path, not the live v2 path.

**Consequence:** engaged == passive in the measured curves (peak ~$98k Accountant / ~$127k Software Engineer, salary flat the whole life). The 5-tier salary ladders on all 30 occupations are decorative. This — not a mis-scaled constant — is why late-game money is meaningless. Measure-first avoided a band-aid rescale on the wrong problem.

**Engaged curve numbers:** see `notes/T003-engaged-curve.md`.

## Key data — the real passive curve (npm run metrics, 3 seeds, near-identical)

| Age | Money / NetWorth | Salary | Occ |
|-----|------------------|--------|-----|
| 18 | $0 | 0 | (none) |
| 23 | ~$4.5k | 1200 | work |
| 33 | $18.8k | 1200 | work |
| 43 | $42.6k | 1200 | work |
| 53 | $66.5k | 1200 | work |
| 63 | $93.0k | 1200 | work |
| ~65 | **peak ~$97.8k** | retire | |

**Findings:**
- **Salary frozen at $1,200/mo for the entire 42-year career** — promotions never fire. Root cause: the sim's employment driver applies for a job but never sets focus=`Work Hard`, so `handleJob` perfUpdate=`randint(-1,1)` random-walks ~0 and never crosses the >90 promotion threshold. This is the PASSIVE-worker floor.
- A whole passive life nets **~$97k** — trivial vs a $100–$5,000,000 shop ⇒ late-game money meaningless for a passive player.
- `netWorth == money` every year (sim buys nothing); single income path (`applyWeeklyFinances`, online+offline) — **no double-counting** (Scout's "two pay paths" was a dead unused interface).
- The metrics harness already drives employment + samples/guards the curve, but its guard band ([$5k,$5M]) is so wide it can't detect "meaningful." The **engaged** curve (Work Hard → climbing tiers; high-ceiling careers SWE→CTO $10k/mo) has never been measured — that's the real deferred "promotion/raise-curve check."

## Receipts / notes

- T001/T002 receipts: see state.yaml.
