Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md
T
josh 63d2d8d5f7 docs(02): create phase 2 plan — 5 plans across 3 waves
Phase 2 (Season 1 Vertical Slice — Soil) plan set:
- 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule)
- 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice
- 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification
- 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10)
- 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e

All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:45:56 -04:00

53 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02 01 execute 0
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
true
CORE-02
CORE-03
CORE-11
UX-10
UX-11
foundations
scheduler
big-qty
zustand
save-extension
mvp
truths artifacts key_links
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
path provides exports
src/sim/numbers/big-qty.ts BigQty immutable wrapper around break_eternity.js Decimal (D-31)
BigQty
path provides exports
src/sim/numbers/format.ts formatHumanReadable(d) — UX-11 K/M/B/T/scientific
formatHumanReadable
path provides
src/sim/numbers/index.ts Public barrel for sim/numbers
path provides exports
src/sim/scheduler/clock.ts Clock interface, wallClock implementation, FakeClock test fixture (D-33). The ONLY file in src/sim/ allowed to call Date.now().
Clock
wallClock
FakeClock
path provides exports
src/sim/scheduler/tick.ts TICK_MS=200, MAX_OFFLINE_MS, drainTicks(state, accumulatorMs, silent) — fixed-timestep accumulator (CORE-02)
TICK_MS
MAX_OFFLINE_MS
drainTicks
path provides exports
src/sim/scheduler/catchup.ts computeOfflineCatchup(savedLastTickAt, nowMs) — clamps to 24h, refuses negative (CORE-03, CORE-11)
computeOfflineCatchup
path provides exports
src/sim/state.ts SimState root shape — structural mirror of V1Payload
SimState
path provides exports
src/store/store.ts appStore (zustand/vanilla createStore) composing 4 slices, useAppStore hook
appStore
useAppStore
AppStoreShape
path provides exports
src/store/sim-adapter.ts simAdapter — applySimResult / drainCommands; sim never imports this
simAdapter
path provides exports
src/save/migrations.ts Phase-2-extended V1Payload (D-34) with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown
migrate
migrations
CURRENT_SCHEMA_VERSION
V1Payload
path provides exports
src/save/lifecycle.ts registerSaveLifecycleHooks() — visibilitychange + beforeunload listeners; saveOnSeasonTransition(state) standalone callable (UX-10)
registerSaveLifecycleHooks
saveOnSeasonTransition
path provides exports
src/game/event-bus.ts Phaser.Events.EventEmitter singleton per Phaser 4 React-template pattern
eventBus
from to via pattern
src/sim/scheduler/tick.ts src/sim/scheduler/clock.ts import type { Clock } from './clock' — tick takes time as injected argument; never reads Date.now itself import type { Clock }
from to via pattern
src/store/sim-adapter.ts src/store/store.ts appStore.setState({...}) and appStore.getState() appStore.(setState|getState)
from to via pattern
src/save/migrations.ts extended V1Payload interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown
from to via pattern
eslint.config.js src/sim/scheduler/clock.ts no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/** 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

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:

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):

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:

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:

import Decimal from 'break_eternity.js';

Step 3 — src/sim/numbers/format.ts (copy RESEARCH lines 588-599 — formatHumanReadable):

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:

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).

/**
 * 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:

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:

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:

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:

/**
 * 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. <acceptance_criteria> - 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) </acceptance_criteria> 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:

/**
 * 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<string, number>;
  harvestedFragmentIds: string[];
  luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
}

Update migrations[1] body to populate the new defaults:

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):

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<GardenSlice, [], [], GardenSlice> = (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:

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<MemorySlice, [], [], MemorySlice> = (set) => ({
  harvestedFragmentIds: [],
  fragmentRevealId: null,
  setHarvested: (ids) => set({ harvestedFragmentIds: ids }),
  setFragmentRevealId: (id) => set({ fragmentRevealId: id }),
});

src/store/narrative-slice.ts:

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<NarrativeSlice, [], [], NarrativeSlice> = (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:

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<SessionSlice, [], [], SessionSlice> = (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):

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<AppStoreShape>()((...a) => ({
  ...createGardenSlice(...a),
  ...createMemorySlice(...a),
  ...createNarrativeSlice(...a),
  ...createSessionSlice(...a),
}));

export function useAppStore<T>(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.

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:

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:

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):

/**
 * 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:

// (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):

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. <acceptance_criteria> - 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 </acceptance_criteria> 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):

// ---------------------------------------------------------------------
// 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):

/**
 * 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):

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. <acceptance_criteria> - 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 </acceptance_criteria> 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.)

<threat_model>

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. </threat_model>

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.

<success_criteria>

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.

</success_criteria>

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).