Two distinct fields with strict separation:
- lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
the application layer. The sim NEVER writes this field.
computeOfflineCatchup uses it as the wall-clock anchor.
- tickCount: monotonic sim-internal counter (one per simulate() call).
Used for STRY-10 narrative gating that must be immune to wall-clock
manipulation. The sim writes this field; the application layer reads
it via simAdapter.applyTickCount.
Changes:
02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
simAdapter exposes applyTickCount; tests assert the round-trip.
02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
currentTick); Garden scene's SimState snapshot reads lastTickAt
through from store and writes tickCount: this.currentTick locally;
acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
02-05: buildPayloadFromStore now persists tickCount (from store);
hydrateStoreFromPayload restores it via state.setTickCount.
This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
56 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 |
|
true |
|
|
|
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).
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.mdFrom 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 toformatHumanReadable),toNumberSaturating()(returns Number.MAX_SAFE_INTEGER if Decimal.gte(MAX_SAFE_INTEGER)). - Serialization:
toJSON()returnsthis.d.toString(); staticfromJSON(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/fromJSONround-trip — fromString('1e100').toJSON() round-trips to a value that .eq() the original.toNumberSaturating— saturates atNumber.MAX_SAFE_INTEGERfor 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 viaMath.absbranch).
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 satisfyb >= a(allow equal).FakeClockstarts 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)clampsticksAppliedtoMath.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 andexpect.softrather 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.
*
* 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.
<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.
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[];
/**
* 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<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(),
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']}).unlockedPlantTypesdeep-equals[].migrations[1]({...}).luraBeatProgressdeep-equals{arrived: false, mid: false, farewell: false, pending: null}.migrations[1]({...}).offlineEventsisnull.migrations[1]({...}).settings.persistenceToastShownisfalse.migrations[1]({...}).settings.musicVolumeis0.7(existing value preserved).migrations[1]({...}).tickCountis0(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
*Sliceinterface (state fields + setter actions for queueing commands). - A
create*Slicefactory that takes Zustand'ssetandgetand 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[];
/** 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<GardenSlice, [], [], GardenSlice> = (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:
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);
},
/** 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:
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,tickCount,lastTickAt). - Command enqueue+drain semantics:
enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})thendrainCommands()returns the command and leavespendingCommands === []. - 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/reactre-renders whensetHarvested(['season1.soil.x'])fires. NOTE:@testing-library/reactis NOT installed yet — install it as a devDep before writing this part of the test (npm install -D @testing-library/react). Confirmpackage.jsonreflects the install. - Selector check:
selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})returnstrue.
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
saveSyncis invoked whendocument.dispatchEvent(new Event('visibilitychange'))fires ANDdocument.visibilityState === 'hidden'(useObject.defineProperty(document, 'visibilityState', {value: 'hidden', configurable: true})). saveSyncis invoked whenwindow.dispatchEvent(new Event('beforeunload'))fires.saveOnSeasonTransition(spy)invokesspyexactly 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 -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
</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.
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:
- Linter:
npm run lintexits 0. - Tests:
npx vitest runexits 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. - Build:
npm run buildexits 0 (tsc -b && vite build— strict TS gate). - Full CI:
npm run ciexits 0. - 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. - Schema lock:
grep -c "^ [0-9]:" src/save/migrations.tsreturns1— confirms nomigrations[2]was added.
<success_criteria>
Plan 02-01 is complete when:
- All 3 tasks committed with conventional-commit messages prefixed with
feat(02-01):orchore(02-01):. npm run ciexits 0.- BigQty wraps
break_eternity.jsand 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).