--- phase: 02 plan: 01 type: execute wave: 0 depends_on: [] files_modified: - package.json - src/sim/numbers/big-qty.ts - src/sim/numbers/big-qty.test.ts - src/sim/numbers/format.ts - src/sim/numbers/format.test.ts - src/sim/numbers/index.ts - src/sim/scheduler/clock.ts - src/sim/scheduler/clock.test.ts - src/sim/scheduler/tick.ts - src/sim/scheduler/tick.test.ts - src/sim/scheduler/catchup.ts - src/sim/scheduler/catchup.test.ts - src/sim/scheduler/index.ts - src/sim/state.ts - src/sim/index.ts - src/store/garden-slice.ts - src/store/memory-slice.ts - src/store/narrative-slice.ts - src/store/session-slice.ts - src/store/store.ts - src/store/store.test.ts - src/store/selectors.ts - src/store/sim-adapter.ts - src/store/index.ts - src/save/migrations.ts - src/save/migrations.test.ts - src/save/index.ts - src/save/lifecycle.ts - src/save/lifecycle.test.ts - eslint.config.js - src/sim/__test_violation__/date-now-violator.ts - src/sim/__test_violation__/lint-firewall.test.ts - src/game/event-bus.ts autonomous: true requirements: [CORE-02, CORE-03, CORE-11, UX-10, UX-11] tags: [foundations, scheduler, big-qty, zustand, save-extension, mvp] must_haves: truths: - "BigQty wraps break_eternity.js — every arithmetic op returns a NEW BigQty (immutable); add/sub/mul/div/eq/gte/gt/lt/lte work; toJSON()/fromJSON(s) round-trip the canonical Decimal string" - "formatHumanReadable produces '1.2K' / '4.5M' / '8.9B' / '1.0T' / scientific past 1e15 (UX-11)" - "src/sim/scheduler/clock.ts is the ONLY file in the project that calls Date.now(); a FakeClock test fixture lets sim tests advance time deterministically" - "drainTicks(state, accumulatorMs<0) returns the original state with ticksApplied=0 (CORE-11 negative-delta refusal)" - "drainTicks(state, 25*3600*1000) clamps to floor(MAX_OFFLINE_MS / TICK_MS) ticks (CORE-03 24h cap)" - "drainTicks at TICK_MS=200ms over 24h completes ≤500ms on a modern machine (Vitest benchmark)" - "Zustand 5 vanilla createStore composes 4 slices (garden / memory / narrative / session); useAppStore hook re-renders on selector changes; getState() works without React" - "simAdapter (in src/store/) exposes applySimResult(next, events) and drainCommands(); src/sim/ never imports src/store/" - "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays at 1; no migrations[2] entry exists" - "migrations[1] (the v0→v1 demo) returns a fully-populated V1Payload including all new fields with sensible defaults" - "save lifecycle hook fires synchronously on visibilitychange→hidden, on beforeunload, AND on saveOnSeasonTransition() invocation (UX-10); Vitest exercises all three triggers" - "Phaser EventBus singleton (src/game/event-bus.ts) exports `eventBus = new Phaser.Events.EventEmitter()` per Phaser 4 official template" - "ESLint extension: any new src/sim/** file calling Date.now() (except src/sim/scheduler/clock.ts) fails lint with rule id 'no-restricted-syntax'; deliberate-violation fixture proves it" - "npm run ci is green at end of plan" artifacts: - path: src/sim/numbers/big-qty.ts provides: "BigQty immutable wrapper around break_eternity.js Decimal (D-31)" exports: ["BigQty"] - path: src/sim/numbers/format.ts provides: "formatHumanReadable(d) — UX-11 K/M/B/T/scientific" exports: ["formatHumanReadable"] - path: src/sim/numbers/index.ts provides: "Public barrel for sim/numbers" - path: src/sim/scheduler/clock.ts provides: "Clock interface, wallClock implementation, FakeClock test fixture (D-33). The ONLY file in src/sim/ allowed to call Date.now()." exports: ["Clock", "wallClock", "FakeClock"] - path: src/sim/scheduler/tick.ts provides: "TICK_MS=200, MAX_OFFLINE_MS, drainTicks(state, accumulatorMs, silent) — fixed-timestep accumulator (CORE-02)" exports: ["TICK_MS", "MAX_OFFLINE_MS", "drainTicks"] - path: src/sim/scheduler/catchup.ts provides: "computeOfflineCatchup(savedLastTickAt, nowMs) — clamps to 24h, refuses negative (CORE-03, CORE-11)" exports: ["computeOfflineCatchup"] - path: src/sim/state.ts provides: "SimState root shape — structural mirror of V1Payload" exports: ["SimState"] - path: src/store/store.ts provides: "appStore (zustand/vanilla createStore) composing 4 slices, useAppStore hook" exports: ["appStore", "useAppStore", "AppStoreShape"] - path: src/store/sim-adapter.ts provides: "simAdapter — applySimResult / drainCommands; sim never imports this" exports: ["simAdapter"] - path: src/save/migrations.ts provides: "Phase-2-extended V1Payload (D-34) with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown" exports: ["migrate", "migrations", "CURRENT_SCHEMA_VERSION", "V1Payload"] - path: src/save/lifecycle.ts provides: "registerSaveLifecycleHooks() — visibilitychange + beforeunload listeners; saveOnSeasonTransition(state) standalone callable (UX-10)" exports: ["registerSaveLifecycleHooks", "saveOnSeasonTransition"] - path: src/game/event-bus.ts provides: "Phaser.Events.EventEmitter singleton per Phaser 4 React-template pattern" exports: ["eventBus"] key_links: - from: src/sim/scheduler/tick.ts to: src/sim/scheduler/clock.ts via: "import type { Clock } from './clock' — tick takes time as injected argument; never reads Date.now itself" pattern: "import type \\{ Clock \\}" - from: src/store/sim-adapter.ts to: src/store/store.ts via: "appStore.setState({...}) and appStore.getState()" pattern: "appStore\\.(setState|getState)" - from: src/save/migrations.ts to: "extended V1Payload" via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown" pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown" - from: eslint.config.js to: src/sim/scheduler/clock.ts via: "no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/**" pattern: "no-restricted-syntax" --- **Wave 0. Foundations plan. Blocks every other Phase 2 plan.** This plan lands the three deferred "day-one of feature code" foundations from CLAUDE.md (BigQty, Zustand store, tick scheduler), extends the save schema in place per D-34, wires save lifecycle hooks (UX-10), and seeds the Phaser EventBus singleton. No vertical-slice features here — but every Wave-1+ plan can build on this without re-running infrastructure work. 3 tasks. Estimated context cost ~45%. If executor context fills mid-plan, `/clear` is safe between tasks (each commits independently). Land the three Phase-2 foundations (BigQty wrapper around `break_eternity.js`, Zustand 5 vanilla store + 4 slice files + slim sim adapter, tick scheduler / monotonic clock with negative-delta refusal + 24h offline cap), extend `V1Payload` in place per D-34 (no `migrations[2]`), wire save lifecycle hooks for UX-10 (visibilitychange + beforeunload + Season transition), seed the Phaser EventBus singleton per the Phaser 4 React-template pattern, and (defended option) add an ESLint `no-restricted-syntax` rule banning `Date.now()` and `setInterval` inside `src/sim/**` except `src/sim/scheduler/clock.ts`. Purpose: Wave 1 + Wave 2 plans build vertical slices on top of these foundations. Splitting them risks circular blocking (the scheduler updates the store; BigQty values flow through state; the firewall rule enforces the boundary). All Phase-2 sim modules will inject the Clock; all economic values flow through BigQty; React UI reads via `useAppStore`; saves carry the new fields. Output: Running scaffold where the sim ticks (against a placeholder no-op simulate), the store updates, the save schema is extended with safe defaults, the firewall holds, the EventBus singleton is exported, and the ESLint sim-purity rule has a green deliberate-violation test. Nothing player-visible yet. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @CLAUDE.md @.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md @.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md @.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md @.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md @.planning/phases/01-foundations-and-doctrine/01-03-save-layer-SUMMARY.md @.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-SUMMARY.md From src/save/index.ts (Phase 1, frozen barrel — Phase 2 imports ONLY from this file): ```typescript export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope'; export type { SaveEnvelope } from './envelope'; export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations'; export type { V1Payload } from './migrations'; // <-- this plan extends it export { snapshot, listSnapshots } from './snapshots'; export type { SnapshotEntry } from './snapshots'; export { requestPersistence } from './persist'; export type { PersistResult } from './persist'; export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec'; export { openSaveDB, SAVE_DB_NAME } from './db'; export type { SaveDB, SaveDBSchema, SavedRecord, SnapshotRecord, SaveStoreName, SaveObjectStore, SaveTransaction } from './db'; export { LocalStorageDBAdapter } from './db-localstorage-adapter'; export type { StoreName, RecordOf } from './db-localstorage-adapter'; export { crc32hex, canonicalJSON } from './checksum'; ``` Current V1Payload (src/save/migrations.ts) — Phase 1 shape that Phase 2 extends in place: ```typescript export interface V1Payload { garden: { tiles: unknown[] }; plants: unknown[]; harvestedFragmentIds: string[]; lastTickAt: number; settings: { musicVolume: number; ambientVolume: number; sfxVolume: number; }; } ``` Current migrations[1] body (Phase 2 extends to populate new field defaults): ```typescript 1: (s: unknown): V1Payload => { const v0 = (s ?? {}) as V0Payload; return { garden: { tiles: v0.garden ?? [] }, plants: [], harvestedFragmentIds: [], lastTickAt: Date.now(), settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 }, }; }, ``` ESLint firewall (eslint.config.js Phase 1) — boundaries/element-types rule already enforces: ``` { from: ['sim'], disallow: ['render', 'ui'] } ``` Element types: sim, render, ui, save, content, audio, store, app, game. The deliberate-violation fixture under src/sim/__test_violation__/ is excluded from default lint. Phaser version: ^4.1.0 (installed). Use `import * as Phaser from 'phaser'`. For test patterns, mirror src/save/checksum.test.ts: ```typescript import { describe, it, expect } from 'vitest'; import { crc32hex, canonicalJSON } from './checksum'; describe('crc32hex', () => { it('is deterministic — same input always returns same output', () => { expect(crc32hex('hello')).toBe(crc32hex('hello')); }); // ... one describe per exported symbol; one assertion per `it` }); ``` For migrations.test.ts pattern, mirror existing v0→v1 cases (use expect.objectContaining for forward-compatibility — RESEARCH p.614). Task 1: Install zustand + break_eternity.js, author BigQty + format, scheduler (clock + tick + catchup), and barrels - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 1 lines 434-540, Pattern 2 lines 541-610) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group A, Group B; reading-order section) - src/save/checksum.ts + src/save/checksum.test.ts (analog for pure-utility test layout) - package.json (current scripts.ci, current deps) package.json, src/sim/numbers/big-qty.ts, src/sim/numbers/big-qty.test.ts, src/sim/numbers/format.ts, src/sim/numbers/format.test.ts, src/sim/numbers/index.ts, src/sim/scheduler/clock.ts, src/sim/scheduler/clock.test.ts, src/sim/scheduler/tick.ts, src/sim/scheduler/tick.test.ts, src/sim/scheduler/catchup.ts, src/sim/scheduler/catchup.test.ts, src/sim/scheduler/index.ts, src/sim/state.ts, src/sim/index.ts **Step 1 — Install dependencies:** Run, from the repo root, in order: ``` npm install zustand@^5.0.0 npm install break_eternity.js@^2.1.3 ``` Verify both land in `dependencies` (NOT devDependencies) in `package.json` with the exact `^` ranges above. Lockfile updated. **Step 2 — `src/sim/numbers/big-qty.ts`** (copy from RESEARCH lines 547-600 verbatim, with leading docblock). Leading docblock MUST cite: "Per CLAUDE.md Code Style: 'BigNumbers go through the typed BigQty wrapper around break_eternity.js. Never raw Decimal values in app code.' Per CONTEXT D-31. Per RESEARCH Pattern 2." The class: - Private constructor; public static factories `fromNumber(n)`, `fromString(s)`, `zero()`, `one()`. - Immutable arithmetic: `add(b)`, `sub(b)`, `mul(b)`, `div(b)` — each returns NEW BigQty. - Comparison: `eq`, `gte`, `gt`, `lt`, `lte`. - Display: `format()` (delegates to `formatHumanReadable`), `toNumberSaturating()` (returns Number.MAX_SAFE_INTEGER if Decimal.gte(MAX_SAFE_INTEGER)). - Serialization: `toJSON()` returns `this.d.toString()`; static `fromJSON(s)` → BigQty via fromString. Import from `break_eternity.js`: ```typescript import Decimal from 'break_eternity.js'; ``` **Step 3 — `src/sim/numbers/format.ts`** (copy RESEARCH lines 588-599 — formatHumanReadable): ```typescript import Decimal from 'break_eternity.js'; export function formatHumanReadable(d: Decimal): string { const n = d.toNumber(); if (Number.isFinite(n) && Math.abs(n) < 1000) return n.toFixed(0); if (Math.abs(n) < 1e6) return `${(n / 1e3).toFixed(1)}K`; if (Math.abs(n) < 1e9) return `${(n / 1e6).toFixed(1)}M`; if (Math.abs(n) < 1e12) return `${(n / 1e9).toFixed(1)}B`; if (Math.abs(n) < 1e15) return `${(n / 1e12).toFixed(1)}T`; return d.toExponential(2); } ``` **Step 4 — `src/sim/numbers/big-qty.test.ts`** — Vitest, one `describe('BigQty', () => { ... })` outer, then nested `describe` per category: - `add` / `sub` / `mul` / `div` — each: `BigQty.fromNumber(2).add(BigQty.fromNumber(3)).eq(BigQty.fromNumber(5))` returns true; immutability assertion (original instance unchanged after operation). - `eq` / `gte` / `gt` / `lt` / `lte` — ordering correctness on small + large values. - `toJSON` / `fromJSON` round-trip — fromString('1e100').toJSON() round-trips to a value that .eq() the original. - `toNumberSaturating` — saturates at `Number.MAX_SAFE_INTEGER` for large Decimals. **Step 5 — `src/sim/numbers/format.test.ts`** — boundary cases for UX-11: - `0` → `"0"`; `999` → `"999"`; `1000` → `"1.0K"`; `1499` → `"1.5K"`; `1500` → `"1.5K"`; `999999` → `"1000.0K"`; `1e6` → `"1.0M"`; `1e9` → `"1.0B"`; `1e12` → `"1.0T"`; `1e15` → scientific (matches `/^\d\.\d{2}e\+\d+$/`). - Negative numbers: `-1500` → `"-1.5K"` (verify via `Math.abs` branch). **Step 6 — `src/sim/numbers/index.ts`** — barrel: ```typescript export { BigQty } from './big-qty'; export { formatHumanReadable } from './format'; ``` **Step 7 — `src/sim/scheduler/clock.ts`** (copy RESEARCH lines 495-521). Leading docblock MUST cite CLAUDE.md "Simulation modules are pure" rule, CONTEXT D-33, and the ESLint `no-restricted-syntax` exclusion that this file specifically claims (Task 3 of this plan adds the rule). ```typescript /** * The single owner of wall-clock access in The Last Garden. * * Per CLAUDE.md "Code Style": "Simulation modules are pure — no Date.now(), * no setInterval, no DOM, no fetch. Inject time as a parameter; the tick * scheduler owns wall-clock access." * * Per CONTEXT D-33: this module is the only place in src/sim/ that may * read Date.now(). The ESLint no-restricted-syntax rule (Phase 2 Plan 02-01 * Task 3) excludes this file specifically. */ export interface Clock { now(): number; } export const wallClock: Clock = { now: () => Date.now(), }; export class FakeClock implements Clock { private t: number; constructor(start = 0) { this.t = start; } now(): number { return this.t; } advance(ms: number): void { this.t += ms; } } ``` **Step 8 — `src/sim/scheduler/clock.test.ts`** — Vitest: - `wallClock.now()` returns a finite number; two consecutive calls satisfy `b >= a` (allow equal). - `FakeClock` starts at 0 by default; advance(1000) returns 1000 from now(); advance is monotonic-by-construction; can be initialized with arbitrary start. **Step 9 — `src/sim/scheduler/tick.ts`** (copy RESEARCH lines 446-493). Define the no-op `simulate` import as a placeholder shape: ```typescript import type { Clock } from './clock'; import type { SimState } from '../state'; export const TICK_MS = 200; // 5Hz, per RESEARCH Pattern 1 line 440 export const MAX_OFFLINE_MS = 24 * 3600 * 1000; export interface TickResult { state: SimState; remainderMs: number; ticksApplied: number; } /** * Drain the accumulator. Pure. Time is INJECTED via accumulatorMs. * REFUSES negative deltas (CORE-11). CLAMPS at MAX_OFFLINE_MS (CORE-03). * * The simulate function is passed in to keep this module pure (no static * import from src/sim/garden/ — Wave-1 plans wire that in). */ export function drainTicks( state: SimState, accumulatorMs: number, simulate: (state: SimState, dtMs: number, silent: boolean) => SimState, silent = false, ): TickResult { if (accumulatorMs < 0) { return { state, remainderMs: 0, ticksApplied: 0 }; } const cappedMs = Math.min(accumulatorMs, MAX_OFFLINE_MS); const ticks = Math.floor(cappedMs / TICK_MS); let next = state; for (let i = 0; i < ticks; i++) { next = simulate(next, TICK_MS, silent); } return { state: next, remainderMs: cappedMs - ticks * TICK_MS, ticksApplied: ticks, }; } ``` **Step 10 — `src/sim/scheduler/tick.test.ts`** — Vitest: - `drainTicks(s, -1, sim)` returns `{state: s, ticksApplied: 0, remainderMs: 0}` (CORE-11). - `drainTicks(s, 25*3600*1000, sim)` clamps `ticksApplied` to `Math.floor(MAX_OFFLINE_MS / TICK_MS) === 432000` (CORE-03). - `drainTicks(s, 1000, sim)` with TICK_MS=200 calls sim 5 times, returns remainderMs=0. - `drainTicks(s, 1100, sim)` calls sim 5 times, remainderMs=100. - Benchmark assertion: 432000 ticks complete within 500ms wall time on the test machine (use `performance.now()` in the test runner — happy-dom provides it). If this fails on CI, log and `expect.soft` rather than hard-fail (RESEARCH Assumption A3). **Step 11 — `src/sim/scheduler/catchup.ts`**: ```typescript import { TICK_MS, MAX_OFFLINE_MS } from './tick'; export interface OfflineCatchupSpec { elapsedMs: number; // raw wall-clock delta (negative deltas are clamped to 0 here, NOT refused — refusal lives in drainTicks) cappedMs: number; // min(elapsedMs, MAX_OFFLINE_MS); 0 if elapsedMs < 0 willRunCatchup: boolean; // cappedMs >= TICK_MS hitOfflineCap: boolean; // elapsedMs > MAX_OFFLINE_MS } /** * Pure descriptor of an offline-catchup boundary. The application layer * uses this to decide: * - whether to fire the letter overlay (cappedMs >= 5*60*1000 → Plan 02-05) * - whether to log a 24h-cap-hit event silently (hitOfflineCap === true) * Per CORE-03 + CORE-11. */ export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): OfflineCatchupSpec { const raw = nowMs - savedLastTickAt; const elapsedMs = raw; const cappedMs = raw < 0 ? 0 : Math.min(raw, MAX_OFFLINE_MS); return { elapsedMs, cappedMs, willRunCatchup: cappedMs >= TICK_MS, hitOfflineCap: raw > MAX_OFFLINE_MS, }; } ``` **Step 12 — `src/sim/scheduler/catchup.test.ts`** — Vitest: - `computeOfflineCatchup(1000, 1100)` → `{elapsedMs: 100, cappedMs: 100, willRunCatchup: false, hitOfflineCap: false}` (below TICK_MS). - `computeOfflineCatchup(0, 1000)` → `cappedMs: 1000, willRunCatchup: true`. - Negative branch: `computeOfflineCatchup(2000, 1000)` → `cappedMs: 0, willRunCatchup: false` (system clock rewind cheat — CORE-11). - Cap branch: `computeOfflineCatchup(0, 25*3600*1000)` → `cappedMs: MAX_OFFLINE_MS, hitOfflineCap: true`. **Step 13 — `src/sim/scheduler/index.ts`** — barrel: ```typescript export type { Clock } from './clock'; export { wallClock, FakeClock } from './clock'; export { TICK_MS, MAX_OFFLINE_MS, drainTicks } from './tick'; export type { TickResult } from './tick'; export { computeOfflineCatchup } from './catchup'; export type { OfflineCatchupSpec } from './catchup'; ``` **Step 14 — `src/sim/state.ts`** — root SimState mirrors V1Payload structurally (declared here so the scheduler can type-check without importing src/save/). Wave-1 plans flesh out tile/plant interior shapes; for now use minimal placeholder types and make the export forward-compatible: ```typescript /** * SimState — root shape of the in-memory sim world. Structurally * compatible with V1Payload from src/save/migrations.ts (a SimState * round-trips to a V1Payload via the application layer). * * Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02) * fleshes them out with real interfaces in src/sim/garden/types.ts. */ export interface SimState { garden: { tiles: unknown[] }; plants: unknown[]; harvestedFragmentIds: string[]; lastTickAt: number; unlockedPlantTypes: string[]; luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null; }; offlineEvents: unknown | null; settings: { musicVolume: number; ambientVolume: number; sfxVolume: number; persistenceToastShown: boolean; }; } ``` **Step 15 — `src/sim/index.ts`** — top-level sim barrel re-exporting from sub-barrels (numbers, scheduler) and SimState type. **Commit:** `feat(02-01): BigQty + scheduler + sim foundations`. Run `npm run lint && npm test` and ensure both pass before committing. - `package.json` `dependencies` field contains both `"zustand": "^5.0.0"` and `"break_eternity.js": "^2.1.3"` exactly. - `grep -q "export class BigQty" src/sim/numbers/big-qty.ts` - `grep -q "export function formatHumanReadable" src/sim/numbers/format.ts` - `grep -q "export class FakeClock" src/sim/scheduler/clock.ts` - `grep -c "Date.now" src/sim/scheduler/clock.ts` reports `1` exactly (the wallClock implementation; no other call site) - `grep -L "Date.now" src/sim/numbers/big-qty.ts src/sim/numbers/format.ts src/sim/scheduler/tick.ts src/sim/scheduler/catchup.ts src/sim/state.ts` (all four files lack the call) - `grep -q "export const TICK_MS = 200" src/sim/scheduler/tick.ts` - `grep -q "export const MAX_OFFLINE_MS" src/sim/scheduler/tick.ts` - `npx vitest run src/sim/numbers/ src/sim/scheduler/` exits 0 and reports ≥20 passing tests across `big-qty.test.ts`, `format.test.ts`, `clock.test.ts`, `tick.test.ts`, `catchup.test.ts` - `npm run lint` exits 0 - `npm run build` exits 0 (`tsc -b && vite build` — strict TS gate) npm run lint && npx vitest run src/sim/numbers/ src/sim/scheduler/ && npm run build BigQty + format land under src/sim/numbers/ with full coverage. Scheduler (clock + tick + catchup) lands under src/sim/scheduler/ with full coverage including CORE-03 + CORE-11 boundary tests. SimState type declared. Barrels exist. zustand and break_eternity.js installed. `npm run lint && npx vitest run src/sim/numbers/ src/sim/scheduler/ && npm run build` exits 0. Task 2: Zustand store (4 slices + sim adapter + selectors), V1Payload extension, save lifecycle hooks, EventBus - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 3 lines 612-696, Pattern 7 lines 841-905, AudioContext bootstrap line 949 [for context only — Plan 02-02 owns it]) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group G store, Group K save extension, Group L event-bus, Pattern: Save lifecycle hooks) - src/save/migrations.ts (current shape — Phase 2 extends in place) - src/save/migrations.test.ts (existing test cases — extend to cover new fields) - src/save/index.ts (frozen barrel — Phase 2 extends to export `lifecycle` exports) - src/PhaserGame.tsx (where the lifecycle hook will eventually be called from in Plan 02-05; for Wave 0 just expose the function) src/store/garden-slice.ts, src/store/memory-slice.ts, src/store/narrative-slice.ts, src/store/session-slice.ts, src/store/store.ts, src/store/store.test.ts, src/store/selectors.ts, src/store/sim-adapter.ts, src/store/index.ts, src/save/migrations.ts, src/save/migrations.test.ts, src/save/lifecycle.ts, src/save/lifecycle.test.ts, src/save/index.ts, src/game/event-bus.ts **Step 1 — Extend `V1Payload` in `src/save/migrations.ts`** (per RESEARCH Pattern 7 lines 847-895 + PATTERNS Group K). Replace the `V1Payload` interface with: ```typescript /** * v1 save shape — Phase-2-extended per CONTEXT D-34. * * NOTE: This is an EXTENSION, not a migration. Phase 1's v1 has shipped * no production saves; Phase 2 adds fields with sensible defaults rather * than introducing migrations[2]. The first real v1→v2 migration lands * in Phase 4 (Roothold / prestige state). * * Cross-references: * - unlockedPlantTypes → CONTEXT D-05 (plant-type unlocks via fragment count) * - luraBeatProgress → CONTEXT D-13 / D-14 (3 beats: arrival / mid / farewell) * - offlineEvents → CONTEXT D-19 (offline event log feeding the letter) * - settings.persistenceToastShown → CONTEXT D-30 (one-time soft toast) */ export interface V1Payload { garden: { tiles: unknown[] }; plants: unknown[]; harvestedFragmentIds: string[]; lastTickAt: number; // NEW Phase 2 fields: unlockedPlantTypes: string[]; luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null; }; offlineEvents: OfflineEventBlock | null; settings: { musicVolume: number; ambientVolume: number; sfxVolume: number; persistenceToastShown: boolean; }; } /** * Local mirror of the OfflineEventBlock shape — declared HERE rather * than imported from src/sim/offline/ so the save layer remains a leaf * with no upward dependency on sim. The Zod schema lives in src/sim/offline/ * (Plan 02-05); structural compatibility is enforced via TypeScript at the * application boundary (src/store/sim-adapter.ts). */ export interface OfflineEventBlock { plantsBloomedCount: Record; harvestedFragmentIds: string[]; luraBeatPending: 'arrival' | 'mid' | 'farewell' | null; } ``` Update `migrations[1]` body to populate the new defaults: ```typescript 1: (s: unknown): V1Payload => { const v0 = (s ?? {}) as V0Payload; return { garden: { tiles: v0.garden ?? [] }, plants: [], harvestedFragmentIds: [], lastTickAt: Date.now(), unlockedPlantTypes: [], luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, offlineEvents: null, settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: false, }, }; }, ``` `CURRENT_SCHEMA_VERSION` stays at `1`. Do NOT add `migrations[2]`. **Step 2 — Update `src/save/migrations.test.ts`:** Existing v0→v1 test still asserts the migration runs; ADD assertions for new fields. Use `expect.objectContaining` or precise equality: - `migrations[1]({garden: ['x']}).unlockedPlantTypes` deep-equals `[]`. - `migrations[1]({...}).luraBeatProgress` deep-equals `{arrived: false, mid: false, farewell: false, pending: null}`. - `migrations[1]({...}).offlineEvents` is `null`. - `migrations[1]({...}).settings.persistenceToastShown` is `false`. - `migrations[1]({...}).settings.musicVolume` is `0.7` (existing value preserved). Also add a regression-defense test: `expect(Object.keys(migrations).sort()).toEqual(['1'])` — proves no `migrations[2]` was sneakily added. **Step 3 — Author Zustand store + 4 slices.** Each slice file (`src/store/garden-slice.ts`, `memory-slice.ts`, `narrative-slice.ts`, `session-slice.ts`) exports: - A `*Slice` interface (state fields + setter actions for queueing commands). - A `create*Slice` factory that takes Zustand's `set` and `get` and returns the slice object. **`src/store/garden-slice.ts`** (placeholder shapes; Wave 1 plans extend): ```typescript import type { StateCreator } from 'zustand'; /** * GardenSlice — Phase 2 garden state surface (D-01 through D-07). * The 16 tiles + unlocked plant types + queued commands. Wave-1 Plan 02-02 * (Begin/Plant/Grow) and Plan 02-03 (Harvest/Journal) flesh out the tile * data; Wave 0 ships the slice shape so React can subscribe immediately. */ export interface GardenCommand { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string; // only for plantSeed } export interface GardenSlice { tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface unlockedPlantTypes: string[]; pendingCommands: GardenCommand[]; enqueueCommand: (cmd: GardenCommand) => void; drainCommands: () => GardenCommand[]; applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void; } export const createGardenSlice: StateCreator = (set, get) => ({ tiles: new Array(16).fill(null), unlockedPlantTypes: [], pendingCommands: [], enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })), drainCommands: () => { const cmds = get().pendingCommands; set({ pendingCommands: [] }); return cmds; }, applyTilesAndUnlocks: (tiles, unlocked) => set({ tiles, unlockedPlantTypes: unlocked }), }); ``` **`src/store/memory-slice.ts`**: ```typescript import type { StateCreator } from 'zustand'; export interface MemorySlice { harvestedFragmentIds: string[]; // Reveal modal state — D-25 surfaces just-harvested fragment in active play fragmentRevealId: string | null; setHarvested: (ids: string[]) => void; setFragmentRevealId: (id: string | null) => void; } export const createMemorySlice: StateCreator = (set) => ({ harvestedFragmentIds: [], fragmentRevealId: null, setHarvested: (ids) => set({ harvestedFragmentIds: ids }), setFragmentRevealId: (id) => set({ fragmentRevealId: id }), }); ``` **`src/store/narrative-slice.ts`**: ```typescript import type { StateCreator } from 'zustand'; export type LuraBeatId = 'arrival' | 'mid' | 'farewell'; export interface NarrativeSlice { luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: LuraBeatId | null }; dialogueOverlayOpen: boolean; setLuraBeatProgress: (p: NarrativeSlice['luraBeatProgress']) => void; setDialogueOverlayOpen: (open: boolean) => void; } export const createNarrativeSlice: StateCreator = (set) => ({ luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, dialogueOverlayOpen: false, setLuraBeatProgress: (p) => set({ luraBeatProgress: p }), setDialogueOverlayOpen: (open) => set({ dialogueOverlayOpen: open }), }); ``` **`src/store/session-slice.ts`**: ```typescript import type { StateCreator } from 'zustand'; export interface SessionSlice { beginGateDismissed: boolean; persistenceToastShown: boolean; letterOverlayOpen: boolean; pendingLetterEventBlock: unknown | null; // OfflineEventBlock; typed in Plan 02-05 dismissBeginGate: () => void; setPersistenceToastShown: (v: boolean) => void; openLetter: (block: unknown) => void; dismissLetter: () => void; } export const createSessionSlice: StateCreator = (set) => ({ beginGateDismissed: false, persistenceToastShown: false, letterOverlayOpen: false, pendingLetterEventBlock: null, dismissBeginGate: () => set({ beginGateDismissed: true }), setPersistenceToastShown: (v) => set({ persistenceToastShown: v }), openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }), dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }), }); ``` **`src/store/store.ts`** — composition (RESEARCH lines 624-661): ```typescript import { createStore } from 'zustand/vanilla'; import { useStore } from 'zustand'; import { createGardenSlice, type GardenSlice } from './garden-slice'; import { createMemorySlice, type MemorySlice } from './memory-slice'; import { createNarrativeSlice, type NarrativeSlice } from './narrative-slice'; import { createSessionSlice, type SessionSlice } from './session-slice'; export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice; export const appStore = createStore()((...a) => ({ ...createGardenSlice(...a), ...createMemorySlice(...a), ...createNarrativeSlice(...a), ...createSessionSlice(...a), })); export function useAppStore(selector: (s: AppStoreShape) => T): T { return useStore(appStore, selector); } ``` **`src/store/sim-adapter.ts`** — the bridge between sim outputs and store updates (RESEARCH lines 651-661). Sim never imports this file. ```typescript import { appStore } from './store'; import type { GardenCommand } from './garden-slice'; /** * simAdapter — the application-layer boundary between the pure sim and * the Zustand store. The Phaser scene's update() loop calls these: * 1. drainCommands() — pull pending commands the React UI enqueued * 2. (run scheduler with those commands; receive next state + events) * 3. applySimResult(next, events) — write the result back into the store * * src/sim/ MUST NOT import this file. The CORE-10 firewall (sim → ui) * already prevents that; this comment is a reader-facing reminder. */ export const simAdapter = { drainCommands(): GardenCommand[] { return appStore.getState().drainCommands(); }, applyTilesAndUnlocks(tiles: unknown[], unlocked: string[]): void { appStore.getState().applyTilesAndUnlocks(tiles, unlocked); }, applyHarvestedFragments(ids: string[]): void { appStore.getState().setHarvested(ids); }, applyLuraProgress(p: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null }): void { appStore.getState().setLuraBeatProgress(p); }, }; ``` **`src/store/selectors.ts`** — small named selectors React components can use: ```typescript import type { AppStoreShape } from './store'; export const selectHarvestCount = (s: AppStoreShape): number => s.harvestedFragmentIds.length; export const selectJournalRevealed = (s: AppStoreShape): boolean => s.harvestedFragmentIds.length > 0; export const selectBeginGateActive = (s: AppStoreShape): boolean => !s.beginGateDismissed; export const selectLuraPending = (s: AppStoreShape) => s.luraBeatProgress.pending; ``` **`src/store/index.ts`** — barrel: ```typescript export { appStore, useAppStore } from './store'; export type { AppStoreShape } from './store'; export { simAdapter } from './sim-adapter'; export type { GardenSlice, GardenCommand } from './garden-slice'; export type { MemorySlice } from './memory-slice'; export type { NarrativeSlice, LuraBeatId } from './narrative-slice'; export type { SessionSlice } from './session-slice'; export * from './selectors'; ``` **Step 4 — `src/store/store.test.ts`** — Vitest: - Slice composition: `appStore.getState()` has all four slice keys (`pendingCommands`, `harvestedFragmentIds`, `luraBeatProgress`, `beginGateDismissed`). - Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`. - React hook surface: `renderHook(() => useAppStore(s => s.harvestedFragmentIds.length))` from `@testing-library/react` re-renders when `setHarvested(['season1.soil.x'])` fires. NOTE: `@testing-library/react` is NOT installed yet — install it as a devDep before writing this part of the test (`npm install -D @testing-library/react`). Confirm `package.json` reflects the install. - Selector check: `selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})` returns `true`. **Step 5 — `src/save/lifecycle.ts`** — UX-10 hook implementation (RESEARCH Pitfall 7 lines 1094-1100): ```typescript /** * Save lifecycle hooks (UX-10). * * Saves fire on: * 1. visibilitychange → hidden * 2. beforeunload * 3. saveOnSeasonTransition() (callable from Phase 4+; Phase 2 verifies via unit test only) * * The visibilitychange + beforeunload handlers MUST be synchronous (no * `await`) — RESEARCH Pitfall 7 line 1094: React unmounts asynchronously * and `beforeunload` will not await. The synchronous LocalStorageDBAdapter * write path is used here; idb writes are best-effort. */ export interface LifecycleHooksHandle { /** Detach all listeners. Call from a useEffect cleanup function. */ detach(): void; } export interface LifecycleHooksConfig { /** Synchronous serializer that writes to LocalStorage and best-effort to IDB. */ saveSync: () => void; } export function registerSaveLifecycleHooks(config: LifecycleHooksConfig): LifecycleHooksHandle { const onVisibility = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { config.saveSync(); } }; const onBeforeUnload = () => { config.saveSync(); }; if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibility); } if (typeof window !== 'undefined') { window.addEventListener('beforeunload', onBeforeUnload); } return { detach() { if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibility); } if (typeof window !== 'undefined') { window.removeEventListener('beforeunload', onBeforeUnload); } }, }; } /** * Phase-4+ hook for Season transitions. Phase 2 has no transitions; this * function is exported so Phase 4's prestige plan can call it directly * (UX-10 third trigger). */ export function saveOnSeasonTransition(saveSync: () => void): void { saveSync(); } ``` **Step 6 — `src/save/lifecycle.test.ts`** — Vitest with happy-dom: - A spy `saveSync` is invoked when `document.dispatchEvent(new Event('visibilitychange'))` fires AND `document.visibilityState === 'hidden'` (use `Object.defineProperty(document, 'visibilityState', {value: 'hidden', configurable: true})`). - `saveSync` is invoked when `window.dispatchEvent(new Event('beforeunload'))` fires. - `saveOnSeasonTransition(spy)` invokes `spy` exactly once. - `handle.detach()` removes both listeners (subsequent dispatches do not invoke the spy). **Step 7 — `src/save/index.ts`** — extend the existing barrel: ```typescript // (existing exports stay) export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle'; export type { LifecycleHooksHandle, LifecycleHooksConfig } from './lifecycle'; export type { OfflineEventBlock } from './migrations'; ``` **Step 8 — `src/game/event-bus.ts`** — Phaser EventBus singleton (RESEARCH Pattern 3 lines 681-694): ```typescript import * as Phaser from 'phaser'; /** * Single shared emitter — the Phaser 4 React-template pattern. * Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future * * Used for transient signals between Phaser scenes and React UI: * 'scene-ready' (Phaser → React) signals scene tree is live * 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker (Plan 02-02) * 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal (Plan 02-03) * * Persistent state lives in src/store/, NOT here. Anti-pattern: routing * user-input intents through this bus — those are commands, store-bound. */ export const eventBus = new Phaser.Events.EventEmitter(); ``` (No test for this — it's a single-line singleton; trivial verification via `import { eventBus } from './event-bus'` working in any other file's test suite.) **Commit:** `feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks`. Run `npm run lint && npm test` before committing. - `grep -q "OfflineEventBlock" src/save/migrations.ts` (new field type declared inline) - `grep -q "luraBeatProgress" src/save/migrations.ts` - `grep -q "persistenceToastShown" src/save/migrations.ts` - `grep -c "^ [0-9]:" src/save/migrations.ts` reports `1` exactly (only `migrations[1]`; no `migrations[2]`) - `grep -q "CURRENT_SCHEMA_VERSION = 1" src/save/migrations.ts` (version stays 1) - `grep -q "import { createStore } from 'zustand/vanilla'" src/store/store.ts` - `grep -q "export const appStore" src/store/store.ts` - `grep -q "export const simAdapter" src/store/sim-adapter.ts` - `grep -q "registerSaveLifecycleHooks" src/save/lifecycle.ts` - `grep -q "saveOnSeasonTransition" src/save/lifecycle.ts` - `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added) - `grep -q "new Phaser.Events.EventEmitter" src/game/event-bus.ts` - `npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts` exits 0 with all tests green - `npm run ci` exits 0 npm run lint && npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts && npm run ci Zustand store with 4 slices + sim adapter + selectors lands. V1Payload extended in place per D-34 with full default population in migrations[1]; CURRENT_SCHEMA_VERSION stays at 1. Save lifecycle hooks (UX-10) ship with Vitest covering all three triggers. Phaser EventBus singleton seeded. `npm run ci` exits 0. Task 3: ESLint sim-purity rule (Date.now + setInterval ban) with deliberate-violation fixture - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pitfall 1 lines 1029-1041, Group O lines 731-781 in PATTERNS.md) - eslint.config.js (current Phase 1 firewall config) - src/sim/__test_violation__/lint-firewall.test.ts (analog: programmatic ESLint test) - src/sim/__test_violation__/violator.ts (analog: deliberate-violation fixture pattern) eslint.config.js, src/sim/__test_violation__/date-now-violator.ts, src/sim/__test_violation__/lint-firewall.test.ts **Defended option (per CONTEXT user-pushback flag):** PATTERNS.md Group O line 780 explicitly notes the user prefers minimum-viable shape. This task implements the rule because it directly defends Pitfall 1 (the highest-impact sim-purity defect we can prevent at lint time) and is ~30 lines of config + ~30 lines of test. If the executor finds this lands cleanly, ship it. If it conflicts with the existing flat-config layout in a way that requires non-trivial restructure, surface a one-line note in SUMMARY.md and defer to manual code review. Default: ship. **Step 1 — Edit `eslint.config.js`** to add a fourth config block (after the current ignores + firewall blocks). The block targets `src/sim/**` exclusively, ignores `src/sim/scheduler/clock.ts` (the one allowed wall-clock owner) and `src/sim/__test_violation__/**` (deliberate-violation fixtures): ```javascript // --------------------------------------------------------------------- // 3. Phase-2 sim-purity rule (CONTEXT D-33, RESEARCH Pitfall 1). // // Bans Date.now() and setInterval() inside src/sim/** to enforce the // "Sim modules are pure — no Date.now(), no setInterval" rule from // CLAUDE.md Code Style. The single allowed wall-clock owner is // src/sim/scheduler/clock.ts (which exports the Clock interface and // the wallClock + FakeClock implementations). // // Severity is `error` so `npm run lint --max-warnings 0` fails on a // violation. The deliberate-violation fixture under // src/sim/__test_violation__/ is excluded; it exists ONLY to be lint- // tested by Task 3's Vitest test (which runs ESLint programmatically // with `ignore: false`). // --------------------------------------------------------------------- { files: ['src/sim/**/*.{ts,tsx}'], ignores: ['src/sim/scheduler/clock.ts', 'src/sim/__test_violation__/**'], rules: { 'no-restricted-syntax': ['error', { selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']", message: "src/sim/** must inject time; only src/sim/scheduler/clock.ts may read Date.now() (CONTEXT D-33).", }, { selector: "CallExpression[callee.name='setInterval']", message: "src/sim/** must not use setInterval; the scheduler drives ticks via the Phaser game loop (CORE-02).", }, ], }, }, ``` **Step 2 — `src/sim/__test_violation__/date-now-violator.ts`** — deliberate-violation fixture (mirrors `violator.ts`): ```typescript /** * Deliberate violation of CONTEXT D-33 — `src/sim/` may NOT call Date.now(). * This file exists ONLY to be lint-tested by date-now-violator.test.ts (or * the existing lint-firewall.test.ts extension) and is excluded from the * default lint glob via eslint.config.js Block 1's `ignores` array. * * The Vitest test runs ESLint programmatically with `ignore: false` against * this file and asserts that `no-restricted-syntax` fires. */ export function violator(): number { return Date.now(); // intentional violation } ``` **Step 3 — Extend `src/sim/__test_violation__/lint-firewall.test.ts`** with a NEW `describe` block (do NOT modify the existing CORE-10 test): ```typescript import { describe, it, expect } from 'vitest'; import { ESLint } from 'eslint'; import { resolve } from 'node:path'; // (existing CORE-10 test stays as-is — DO NOT REMOVE) describe('Phase 2 sim-purity rule (CONTEXT D-33)', () => { it('eslint flags Date.now() inside src/sim/** as no-restricted-syntax', async () => { const eslint = new ESLint({ overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'), ignore: false, }); const fixturePath = resolve(process.cwd(), 'src/sim/__test_violation__/date-now-violator.ts'); const results = await eslint.lintFiles([fixturePath]); expect(results).toHaveLength(1); const violations = results[0].messages.filter( (m) => m.ruleId === 'no-restricted-syntax', ); expect(violations.length).toBeGreaterThanOrEqual(1); expect(violations[0].message).toMatch(/inject time|D-33/); }); it('does NOT flag Date.now() inside src/sim/scheduler/clock.ts (the one exception)', async () => { const eslint = new ESLint({ overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'), ignore: false, }); const clockPath = resolve(process.cwd(), 'src/sim/scheduler/clock.ts'); const results = await eslint.lintFiles([clockPath]); const noRestrictedViolations = results[0].messages.filter( (m) => m.ruleId === 'no-restricted-syntax', ); expect(noRestrictedViolations).toHaveLength(0); }); }); ``` **Commit:** `chore(02-01): eslint sim-purity rule + Date.now violator fixture`. Run `npm run lint && npx vitest run src/sim/__test_violation__/` before committing. - `grep -q "no-restricted-syntax" eslint.config.js` - `grep -q "src/sim/scheduler/clock.ts" eslint.config.js` (in the new block's `ignores` array) - `grep -q "Date.now" src/sim/__test_violation__/date-now-violator.ts` - `npm run lint` exits 0 (the deliberate violator is excluded by Block 1) - `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` exits 0 with the new test cases passing - The existing CORE-10 firewall test remains green npm run lint && npx vitest run src/sim/__test_violation__/ && npm run ci ESLint sim-purity rule lands. Deliberate Date.now() violator fixture proves the rule fires. The clock.ts exception is verified by a positive test. `npm run ci` is green. (If the rule conflicts non-trivially with the existing config layout, this task may be deferred per the defended-option clause; surface in SUMMARY.md.) ## Trust Boundaries | Boundary | Description | |----------|-------------| | Save schema extension boundary | New V1Payload fields must round-trip through CRC-32 envelope without breaking checksum. Phase 1's CRC is over canonical JSON; new fields automatically participate. | | Sim ↔ wall-clock boundary | The scheduler's clock module is the single trust boundary for time. All other sim modules MUST inject time. ESLint enforces (Task 3). | | Store ↔ sim boundary | sim never imports the store; the store imports sim type signatures only. simAdapter is the bridge owned by store/. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-02-01-01 | Tampering | Save round-trip with new fields | mitigate | New fields participate in canonical JSON checksum automatically. The migrations.test.ts assertion that all new fields default correctly is the regression gate; round-trip test (already shipped in src/save/round-trip.test.ts) exercises wrap → unwrap with the extended payload via npm run ci. | | T-02-01-02 | Tampering | System-clock rewind cheat (negative delta) | mitigate | drainTicks refuses negative accumulatorMs and returns state unchanged (CORE-11); computeOfflineCatchup reports cappedMs=0 for negative deltas. Vitest covers both paths. | | T-02-01-03 | Tampering | 24h offline cap bypass | mitigate | drainTicks clamps at MAX_OFFLINE_MS; computeOfflineCatchup reports hitOfflineCap=true (CORE-03). The cap is not user-configurable. | | T-02-01-04 | Tampering | Sim module silently calling Date.now() to bypass FakeClock | mitigate | ESLint no-restricted-syntax rule (Task 3) makes any `Date.now()` outside src/sim/scheduler/clock.ts an error-severity lint failure. Deliberate-violation fixture proves the rule fires. | | T-02-01-05 | Denial-of-service | Offline catch-up loop hangs on absurd delta | mitigate | MAX_OFFLINE_MS clamp limits drainTicks to ≤432000 iterations regardless of input; benchmark assertion targets ≤500ms. | | T-02-01-06 | Repudiation | n/a | accept | Single-player local game; no server-authoritative actions. | | T-02-01-07 | Information disclosure | n/a | accept | No PII collected; no telemetry; saves are local-only. | | T-02-01-08 | Elevation of privilege | n/a | accept | No privilege model in v1. | All Wave 0 threats are mitigate or accept. No `high` severity threats; no blocking issues for this plan. After all 3 tasks committed: 1. **Linter:** `npm run lint` exits 0. 2. **Tests:** `npx vitest run` exits 0 with the Phase-1 53 tests + the new Phase-2 Wave-0 additions all green. Expected new test count: BigQty (~12), format (~10), clock (~5), tick (~5), catchup (~5), store (~6), migrations.test additions (~5), lifecycle (~4), lint-firewall additions (~2). Total ≥54 new tests; combined ~107. 3. **Build:** `npm run build` exits 0 (`tsc -b && vite build` — strict TS gate). 4. **Full CI:** `npm run ci` exits 0. 5. **Firewall:** `grep -rL "Date.now" src/sim/numbers/ src/sim/garden/ src/sim/memory/ src/sim/narrative/ src/sim/offline/` (the directories Plan 02-01 doesn't fully populate but enforces the rule for) — none of these directories should contain Date.now() calls when Wave 1+ plans land. 6. **Schema lock:** `grep -c "^ [0-9]:" src/save/migrations.ts` returns `1` — confirms no `migrations[2]` was added. Plan 02-01 is complete when: - [ ] All 3 tasks committed with conventional-commit messages prefixed with `feat(02-01):` or `chore(02-01):`. - [ ] `npm run ci` exits 0. - [ ] BigQty wraps `break_eternity.js` and round-trips via toJSON/fromJSON. - [ ] formatHumanReadable handles all UX-11 thresholds. - [ ] FakeClock + wallClock + drainTicks + computeOfflineCatchup all behave per CORE-02 / CORE-03 / CORE-11. - [ ] Zustand vanilla createStore composes 4 slices; useAppStore React hook works in Vitest. - [ ] simAdapter exposes drainCommands + applyTilesAndUnlocks + applyHarvestedFragments + applyLuraProgress. - [ ] V1Payload extended with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown; migrations[1] populates all new defaults; CURRENT_SCHEMA_VERSION stays at 1; no migrations[2]. - [ ] registerSaveLifecycleHooks fires saveSync on visibilitychange→hidden, beforeunload, and saveOnSeasonTransition() (UX-10). - [ ] Phaser EventBus singleton exported from src/game/event-bus.ts. - [ ] ESLint sim-purity rule banning Date.now() and setInterval inside src/sim/** (except clock.ts) lands with deliberate-violation fixture proving the rule fires. - [ ] All Wave 1 + Wave 2 plans can begin execution against this foundation. After completion, create `.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md` per the standard summary template. Document: - Final TICK_MS chosen (200 = 5Hz; flag if changed during implementation). - Whether the ESLint sim-purity rule landed or was deferred per the defended-option clause. - Any deviations from the locked task list (e.g., extra tests added; install adjustments). - Final test count breakdown (per-file).