---
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, AND tickCount (BLOCKER 3 — sim-internal monotonic counter, separate from lastTickAt) — 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 (tickCount: 0) with sensible defaults"
- "BLOCKER 3 invariant: SimState.lastTickAt is wall-clock milliseconds (written ONLY at saveSync time by the application layer); SimState.tickCount is the sim-internal monotonic counter (incremented inside simulateOneTick). The sim never writes lastTickAt."
- "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|tickCount"
- 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.
*
* BLOCKER 3 invariant — two distinct time fields with strict separation:
* - lastTickAt: wall-clock milliseconds. Written ONLY by the application
* layer at saveSync time (src/PhaserGame.tsx). The sim NEVER writes
* this field. computeOfflineCatchup reads it as wall-clock ms.
* - tickCount: monotonically-increasing sim-internal counter (one per
* simulate() call). Used for STRY-10 narrative gating that must be
* immune to wall-clock manipulation. The sim DOES write this field.
* The application layer reads it but never writes it.
*/
export interface SimState {
garden: { tiles: unknown[] };
plants: unknown[];
harvestedFragmentIds: string[];
/** Wall-clock milliseconds at last save. Written ONLY at saveSync. */
lastTickAt: number;
/** Monotonic sim tick counter. Incremented by the sim; used for STRY-10. */
tickCount: 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[];
/**
* Wall-clock milliseconds at last save. Per BLOCKER 3 invariant:
* written ONLY at saveSync time by src/PhaserGame.tsx; the sim never
* writes this. computeOfflineCatchup uses it as the wall-clock anchor.
*/
lastTickAt: number;
// NEW Phase 2 fields:
/**
* Monotonic sim tick counter. Incremented inside simulateOneTick.
* Used by STRY-10 narrative gating so beats remain immune to system-
* clock manipulation. Persisted so a returning player resumes at the
* correct tick count rather than restarting at zero.
*/
tickCount: number;
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(),
tickCount: 0, // BLOCKER 3 — fresh sim starts at tick 0
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).
- `migrations[1]({...}).tickCount` is `0` (BLOCKER 3 — sim-internal counter starts fresh).
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[];
/** BLOCKER 3 — sim-internal monotonic counter; written by simAdapter.applyTickCount. */
tickCount: number;
/** BLOCKER 3 — wall-clock ms at last save; read-through from migrated payload. */
lastTickAt: number;
pendingCommands: GardenCommand[];
enqueueCommand: (cmd: GardenCommand) => void;
drainCommands: () => GardenCommand[];
applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void;
/** BLOCKER 3 — write the sim-internal counter into the store. */
setTickCount: (n: number) => void;
/** BLOCKER 3 — write wall-clock ms (used by saveSync's payload build path). */
setLastTickAt: (ms: number) => void;
}
export const createGardenSlice: StateCreator = (set, get) => ({
tiles: new Array(16).fill(null),
unlockedPlantTypes: [],
tickCount: 0,
lastTickAt: 0,
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 }),
setTickCount: (n) => set({ tickCount: n }),
setLastTickAt: (ms) => set({ lastTickAt: ms }),
});
```
**`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);
},
/** BLOCKER 3 — flow the sim's tickCount into the store so saveSync can read it. */
applyTickCount(n: number): void {
appStore.getState().setTickCount(n);
},
};
```
**`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`, `tickCount`, `lastTickAt`).
- Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`.
- BLOCKER 3 round-trip: `setTickCount(7)` updates state.tickCount to 7; `setLastTickAt(1234567)` updates state.lastTickAt to 1234567; both fields default to 0.
- 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 -q "tickCount" src/save/migrations.ts` (BLOCKER 3 — sim-internal counter declared)
- `grep -q "tickCount: 0" src/save/migrations.ts` (BLOCKER 3 — fresh-game default)
- `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 "tickCount" src/store/garden-slice.ts` (BLOCKER 3 — slice owns tickCount)
- `grep -q "setTickCount" src/store/garden-slice.ts`
- `grep -q "applyTickCount" src/store/sim-adapter.ts` (BLOCKER 3 — sim → store flow path)
- `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.