63d2d8d5f7
Phase 2 (Season 1 Vertical Slice — Soil) plan set: - 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule) - 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice - 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification - 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10) - 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1145 lines
53 KiB
Markdown
1145 lines
53 KiB
Markdown
---
|
|
phase: 02
|
|
plan: 01
|
|
type: execute
|
|
wave: 0
|
|
depends_on: []
|
|
files_modified:
|
|
- package.json
|
|
- src/sim/numbers/big-qty.ts
|
|
- src/sim/numbers/big-qty.test.ts
|
|
- src/sim/numbers/format.ts
|
|
- src/sim/numbers/format.test.ts
|
|
- src/sim/numbers/index.ts
|
|
- src/sim/scheduler/clock.ts
|
|
- src/sim/scheduler/clock.test.ts
|
|
- src/sim/scheduler/tick.ts
|
|
- src/sim/scheduler/tick.test.ts
|
|
- src/sim/scheduler/catchup.ts
|
|
- src/sim/scheduler/catchup.test.ts
|
|
- src/sim/scheduler/index.ts
|
|
- src/sim/state.ts
|
|
- src/sim/index.ts
|
|
- src/store/garden-slice.ts
|
|
- src/store/memory-slice.ts
|
|
- src/store/narrative-slice.ts
|
|
- src/store/session-slice.ts
|
|
- src/store/store.ts
|
|
- src/store/store.test.ts
|
|
- src/store/selectors.ts
|
|
- src/store/sim-adapter.ts
|
|
- src/store/index.ts
|
|
- src/save/migrations.ts
|
|
- src/save/migrations.test.ts
|
|
- src/save/index.ts
|
|
- src/save/lifecycle.ts
|
|
- src/save/lifecycle.test.ts
|
|
- eslint.config.js
|
|
- src/sim/__test_violation__/date-now-violator.ts
|
|
- src/sim/__test_violation__/lint-firewall.test.ts
|
|
- src/game/event-bus.ts
|
|
autonomous: true
|
|
requirements: [CORE-02, CORE-03, CORE-11, UX-10, UX-11]
|
|
tags: [foundations, scheduler, big-qty, zustand, save-extension, mvp]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "BigQty wraps break_eternity.js — every arithmetic op returns a NEW BigQty (immutable); add/sub/mul/div/eq/gte/gt/lt/lte work; toJSON()/fromJSON(s) round-trip the canonical Decimal string"
|
|
- "formatHumanReadable produces '1.2K' / '4.5M' / '8.9B' / '1.0T' / scientific past 1e15 (UX-11)"
|
|
- "src/sim/scheduler/clock.ts is the ONLY file in the project that calls Date.now(); a FakeClock test fixture lets sim tests advance time deterministically"
|
|
- "drainTicks(state, accumulatorMs<0) returns the original state with ticksApplied=0 (CORE-11 negative-delta refusal)"
|
|
- "drainTicks(state, 25*3600*1000) clamps to floor(MAX_OFFLINE_MS / TICK_MS) ticks (CORE-03 24h cap)"
|
|
- "drainTicks at TICK_MS=200ms over 24h completes ≤500ms on a modern machine (Vitest benchmark)"
|
|
- "Zustand 5 vanilla createStore composes 4 slices (garden / memory / narrative / session); useAppStore hook re-renders on selector changes; getState() works without React"
|
|
- "simAdapter (in src/store/) exposes applySimResult(next, events) and drainCommands(); src/sim/ never imports src/store/"
|
|
- "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays at 1; no migrations[2] entry exists"
|
|
- "migrations[1] (the v0→v1 demo) returns a fully-populated V1Payload including all new fields with sensible defaults"
|
|
- "save lifecycle hook fires synchronously on visibilitychange→hidden, on beforeunload, AND on saveOnSeasonTransition() invocation (UX-10); Vitest exercises all three triggers"
|
|
- "Phaser EventBus singleton (src/game/event-bus.ts) exports `eventBus = new Phaser.Events.EventEmitter()` per Phaser 4 official template"
|
|
- "ESLint extension: any new src/sim/** file calling Date.now() (except src/sim/scheduler/clock.ts) fails lint with rule id 'no-restricted-syntax'; deliberate-violation fixture proves it"
|
|
- "npm run ci is green at end of plan"
|
|
artifacts:
|
|
- path: src/sim/numbers/big-qty.ts
|
|
provides: "BigQty immutable wrapper around break_eternity.js Decimal (D-31)"
|
|
exports: ["BigQty"]
|
|
- path: src/sim/numbers/format.ts
|
|
provides: "formatHumanReadable(d) — UX-11 K/M/B/T/scientific"
|
|
exports: ["formatHumanReadable"]
|
|
- path: src/sim/numbers/index.ts
|
|
provides: "Public barrel for sim/numbers"
|
|
- path: src/sim/scheduler/clock.ts
|
|
provides: "Clock interface, wallClock implementation, FakeClock test fixture (D-33). The ONLY file in src/sim/ allowed to call Date.now()."
|
|
exports: ["Clock", "wallClock", "FakeClock"]
|
|
- path: src/sim/scheduler/tick.ts
|
|
provides: "TICK_MS=200, MAX_OFFLINE_MS, drainTicks(state, accumulatorMs, silent) — fixed-timestep accumulator (CORE-02)"
|
|
exports: ["TICK_MS", "MAX_OFFLINE_MS", "drainTicks"]
|
|
- path: src/sim/scheduler/catchup.ts
|
|
provides: "computeOfflineCatchup(savedLastTickAt, nowMs) — clamps to 24h, refuses negative (CORE-03, CORE-11)"
|
|
exports: ["computeOfflineCatchup"]
|
|
- path: src/sim/state.ts
|
|
provides: "SimState root shape — structural mirror of V1Payload"
|
|
exports: ["SimState"]
|
|
- path: src/store/store.ts
|
|
provides: "appStore (zustand/vanilla createStore) composing 4 slices, useAppStore hook"
|
|
exports: ["appStore", "useAppStore", "AppStoreShape"]
|
|
- path: src/store/sim-adapter.ts
|
|
provides: "simAdapter — applySimResult / drainCommands; sim never imports this"
|
|
exports: ["simAdapter"]
|
|
- path: src/save/migrations.ts
|
|
provides: "Phase-2-extended V1Payload (D-34) with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown"
|
|
exports: ["migrate", "migrations", "CURRENT_SCHEMA_VERSION", "V1Payload"]
|
|
- path: src/save/lifecycle.ts
|
|
provides: "registerSaveLifecycleHooks() — visibilitychange + beforeunload listeners; saveOnSeasonTransition(state) standalone callable (UX-10)"
|
|
exports: ["registerSaveLifecycleHooks", "saveOnSeasonTransition"]
|
|
- path: src/game/event-bus.ts
|
|
provides: "Phaser.Events.EventEmitter singleton per Phaser 4 React-template pattern"
|
|
exports: ["eventBus"]
|
|
key_links:
|
|
- from: src/sim/scheduler/tick.ts
|
|
to: src/sim/scheduler/clock.ts
|
|
via: "import type { Clock } from './clock' — tick takes time as injected argument; never reads Date.now itself"
|
|
pattern: "import type \\{ Clock \\}"
|
|
- from: src/store/sim-adapter.ts
|
|
to: src/store/store.ts
|
|
via: "appStore.setState({...}) and appStore.getState()"
|
|
pattern: "appStore\\.(setState|getState)"
|
|
- from: src/save/migrations.ts
|
|
to: "extended V1Payload"
|
|
via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown"
|
|
pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown"
|
|
- from: eslint.config.js
|
|
to: src/sim/scheduler/clock.ts
|
|
via: "no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/**"
|
|
pattern: "no-restricted-syntax"
|
|
---
|
|
|
|
<note>
|
|
**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).
|
|
</note>
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@CLAUDE.md
|
|
@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
|
|
@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
|
|
@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
|
|
@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
|
|
@.planning/phases/01-foundations-and-doctrine/01-03-save-layer-SUMMARY.md
|
|
@.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Existing types and exports the executor needs. Extracted from the codebase
|
|
so the executor does not re-explore. Do not call internal modules directly;
|
|
always go through the public barrels. -->
|
|
|
|
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).
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install zustand + break_eternity.js, author BigQty + format, scheduler (clock + tick + catchup), and barrels</name>
|
|
<read_first>
|
|
- .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)
|
|
</read_first>
|
|
<files>
|
|
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
|
|
</files>
|
|
<action>
|
|
**Step 1 — Install dependencies:**
|
|
|
|
Run, from the repo root, in order:
|
|
```
|
|
npm install zustand@^5.0.0
|
|
npm install break_eternity.js@^2.1.3
|
|
```
|
|
|
|
Verify both land in `dependencies` (NOT devDependencies) in `package.json` with the exact `^` ranges above. Lockfile updated.
|
|
|
|
**Step 2 — `src/sim/numbers/big-qty.ts`** (copy from RESEARCH lines 547-600 verbatim, with leading docblock).
|
|
|
|
Leading docblock MUST cite: "Per CLAUDE.md Code Style: 'BigNumbers go through the typed BigQty wrapper around break_eternity.js. Never raw Decimal values in app code.' Per CONTEXT D-31. Per RESEARCH Pattern 2."
|
|
|
|
The class:
|
|
- Private constructor; public static factories `fromNumber(n)`, `fromString(s)`, `zero()`, `one()`.
|
|
- Immutable arithmetic: `add(b)`, `sub(b)`, `mul(b)`, `div(b)` — each returns NEW BigQty.
|
|
- Comparison: `eq`, `gte`, `gt`, `lt`, `lte`.
|
|
- Display: `format()` (delegates to `formatHumanReadable`), `toNumberSaturating()` (returns Number.MAX_SAFE_INTEGER if Decimal.gte(MAX_SAFE_INTEGER)).
|
|
- Serialization: `toJSON()` returns `this.d.toString()`; static `fromJSON(s)` → BigQty via fromString.
|
|
|
|
Import from `break_eternity.js`:
|
|
```typescript
|
|
import Decimal from 'break_eternity.js';
|
|
```
|
|
|
|
**Step 3 — `src/sim/numbers/format.ts`** (copy RESEARCH lines 588-599 — formatHumanReadable):
|
|
|
|
```typescript
|
|
import Decimal from 'break_eternity.js';
|
|
|
|
export function formatHumanReadable(d: Decimal): string {
|
|
const n = d.toNumber();
|
|
if (Number.isFinite(n) && Math.abs(n) < 1000) return n.toFixed(0);
|
|
if (Math.abs(n) < 1e6) return `${(n / 1e3).toFixed(1)}K`;
|
|
if (Math.abs(n) < 1e9) return `${(n / 1e6).toFixed(1)}M`;
|
|
if (Math.abs(n) < 1e12) return `${(n / 1e9).toFixed(1)}B`;
|
|
if (Math.abs(n) < 1e15) return `${(n / 1e12).toFixed(1)}T`;
|
|
return d.toExponential(2);
|
|
}
|
|
```
|
|
|
|
**Step 4 — `src/sim/numbers/big-qty.test.ts`** — Vitest, one `describe('BigQty', () => { ... })` outer, then nested `describe` per category:
|
|
- `add` / `sub` / `mul` / `div` — each: `BigQty.fromNumber(2).add(BigQty.fromNumber(3)).eq(BigQty.fromNumber(5))` returns true; immutability assertion (original instance unchanged after operation).
|
|
- `eq` / `gte` / `gt` / `lt` / `lte` — ordering correctness on small + large values.
|
|
- `toJSON` / `fromJSON` round-trip — fromString('1e100').toJSON() round-trips to a value that .eq() the original.
|
|
- `toNumberSaturating` — saturates at `Number.MAX_SAFE_INTEGER` for large Decimals.
|
|
|
|
**Step 5 — `src/sim/numbers/format.test.ts`** — boundary cases for UX-11:
|
|
- `0` → `"0"`; `999` → `"999"`; `1000` → `"1.0K"`; `1499` → `"1.5K"`; `1500` → `"1.5K"`; `999999` → `"1000.0K"`; `1e6` → `"1.0M"`; `1e9` → `"1.0B"`; `1e12` → `"1.0T"`; `1e15` → scientific (matches `/^\d\.\d{2}e\+\d+$/`).
|
|
- Negative numbers: `-1500` → `"-1.5K"` (verify via `Math.abs` branch).
|
|
|
|
**Step 6 — `src/sim/numbers/index.ts`** — barrel:
|
|
```typescript
|
|
export { BigQty } from './big-qty';
|
|
export { formatHumanReadable } from './format';
|
|
```
|
|
|
|
**Step 7 — `src/sim/scheduler/clock.ts`** (copy RESEARCH lines 495-521).
|
|
|
|
Leading docblock MUST cite CLAUDE.md "Simulation modules are pure" rule, CONTEXT D-33, and the ESLint `no-restricted-syntax` exclusion that this file specifically claims (Task 3 of this plan adds the rule).
|
|
|
|
```typescript
|
|
/**
|
|
* The single owner of wall-clock access in The Last Garden.
|
|
*
|
|
* Per CLAUDE.md "Code Style": "Simulation modules are pure — no Date.now(),
|
|
* no setInterval, no DOM, no fetch. Inject time as a parameter; the tick
|
|
* scheduler owns wall-clock access."
|
|
*
|
|
* Per CONTEXT D-33: this module is the only place in src/sim/ that may
|
|
* read Date.now(). The ESLint no-restricted-syntax rule (Phase 2 Plan 02-01
|
|
* Task 3) excludes this file specifically.
|
|
*/
|
|
|
|
export interface Clock {
|
|
now(): number;
|
|
}
|
|
|
|
export const wallClock: Clock = {
|
|
now: () => Date.now(),
|
|
};
|
|
|
|
export class FakeClock implements Clock {
|
|
private t: number;
|
|
constructor(start = 0) { this.t = start; }
|
|
now(): number { return this.t; }
|
|
advance(ms: number): void { this.t += ms; }
|
|
}
|
|
```
|
|
|
|
**Step 8 — `src/sim/scheduler/clock.test.ts`** — Vitest:
|
|
- `wallClock.now()` returns a finite number; two consecutive calls satisfy `b >= a` (allow equal).
|
|
- `FakeClock` starts at 0 by default; advance(1000) returns 1000 from now(); advance is monotonic-by-construction; can be initialized with arbitrary start.
|
|
|
|
**Step 9 — `src/sim/scheduler/tick.ts`** (copy RESEARCH lines 446-493).
|
|
|
|
Define the no-op `simulate` import as a placeholder shape:
|
|
```typescript
|
|
import type { Clock } from './clock';
|
|
import type { SimState } from '../state';
|
|
|
|
export const TICK_MS = 200; // 5Hz, per RESEARCH Pattern 1 line 440
|
|
export const MAX_OFFLINE_MS = 24 * 3600 * 1000;
|
|
|
|
export interface TickResult {
|
|
state: SimState;
|
|
remainderMs: number;
|
|
ticksApplied: number;
|
|
}
|
|
|
|
/**
|
|
* Drain the accumulator. Pure. Time is INJECTED via accumulatorMs.
|
|
* REFUSES negative deltas (CORE-11). CLAMPS at MAX_OFFLINE_MS (CORE-03).
|
|
*
|
|
* The simulate function is passed in to keep this module pure (no static
|
|
* import from src/sim/garden/ — Wave-1 plans wire that in).
|
|
*/
|
|
export function drainTicks(
|
|
state: SimState,
|
|
accumulatorMs: number,
|
|
simulate: (state: SimState, dtMs: number, silent: boolean) => SimState,
|
|
silent = false,
|
|
): TickResult {
|
|
if (accumulatorMs < 0) {
|
|
return { state, remainderMs: 0, ticksApplied: 0 };
|
|
}
|
|
const cappedMs = Math.min(accumulatorMs, MAX_OFFLINE_MS);
|
|
const ticks = Math.floor(cappedMs / TICK_MS);
|
|
let next = state;
|
|
for (let i = 0; i < ticks; i++) {
|
|
next = simulate(next, TICK_MS, silent);
|
|
}
|
|
return {
|
|
state: next,
|
|
remainderMs: cappedMs - ticks * TICK_MS,
|
|
ticksApplied: ticks,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 10 — `src/sim/scheduler/tick.test.ts`** — Vitest:
|
|
- `drainTicks(s, -1, sim)` returns `{state: s, ticksApplied: 0, remainderMs: 0}` (CORE-11).
|
|
- `drainTicks(s, 25*3600*1000, sim)` clamps `ticksApplied` to `Math.floor(MAX_OFFLINE_MS / TICK_MS) === 432000` (CORE-03).
|
|
- `drainTicks(s, 1000, sim)` with TICK_MS=200 calls sim 5 times, returns remainderMs=0.
|
|
- `drainTicks(s, 1100, sim)` calls sim 5 times, remainderMs=100.
|
|
- Benchmark assertion: 432000 ticks complete within 500ms wall time on the test machine (use `performance.now()` in the test runner — happy-dom provides it). If this fails on CI, log and `expect.soft` rather than hard-fail (RESEARCH Assumption A3).
|
|
|
|
**Step 11 — `src/sim/scheduler/catchup.ts`**:
|
|
|
|
```typescript
|
|
import { TICK_MS, MAX_OFFLINE_MS } from './tick';
|
|
|
|
export interface OfflineCatchupSpec {
|
|
elapsedMs: number; // raw wall-clock delta (negative deltas are clamped to 0 here, NOT refused — refusal lives in drainTicks)
|
|
cappedMs: number; // min(elapsedMs, MAX_OFFLINE_MS); 0 if elapsedMs < 0
|
|
willRunCatchup: boolean; // cappedMs >= TICK_MS
|
|
hitOfflineCap: boolean; // elapsedMs > MAX_OFFLINE_MS
|
|
}
|
|
|
|
/**
|
|
* Pure descriptor of an offline-catchup boundary. The application layer
|
|
* uses this to decide:
|
|
* - whether to fire the letter overlay (cappedMs >= 5*60*1000 → Plan 02-05)
|
|
* - whether to log a 24h-cap-hit event silently (hitOfflineCap === true)
|
|
* Per CORE-03 + CORE-11.
|
|
*/
|
|
export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): OfflineCatchupSpec {
|
|
const raw = nowMs - savedLastTickAt;
|
|
const elapsedMs = raw;
|
|
const cappedMs = raw < 0 ? 0 : Math.min(raw, MAX_OFFLINE_MS);
|
|
return {
|
|
elapsedMs,
|
|
cappedMs,
|
|
willRunCatchup: cappedMs >= TICK_MS,
|
|
hitOfflineCap: raw > MAX_OFFLINE_MS,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 12 — `src/sim/scheduler/catchup.test.ts`** — Vitest:
|
|
- `computeOfflineCatchup(1000, 1100)` → `{elapsedMs: 100, cappedMs: 100, willRunCatchup: false, hitOfflineCap: false}` (below TICK_MS).
|
|
- `computeOfflineCatchup(0, 1000)` → `cappedMs: 1000, willRunCatchup: true`.
|
|
- Negative branch: `computeOfflineCatchup(2000, 1000)` → `cappedMs: 0, willRunCatchup: false` (system clock rewind cheat — CORE-11).
|
|
- Cap branch: `computeOfflineCatchup(0, 25*3600*1000)` → `cappedMs: MAX_OFFLINE_MS, hitOfflineCap: true`.
|
|
|
|
**Step 13 — `src/sim/scheduler/index.ts`** — barrel:
|
|
```typescript
|
|
export type { Clock } from './clock';
|
|
export { wallClock, FakeClock } from './clock';
|
|
export { TICK_MS, MAX_OFFLINE_MS, drainTicks } from './tick';
|
|
export type { TickResult } from './tick';
|
|
export { computeOfflineCatchup } from './catchup';
|
|
export type { OfflineCatchupSpec } from './catchup';
|
|
```
|
|
|
|
**Step 14 — `src/sim/state.ts`** — root SimState mirrors V1Payload structurally (declared here so the scheduler can type-check without importing src/save/). Wave-1 plans flesh out tile/plant interior shapes; for now use minimal placeholder types and make the export forward-compatible:
|
|
|
|
```typescript
|
|
/**
|
|
* SimState — root shape of the in-memory sim world. Structurally
|
|
* compatible with V1Payload from src/save/migrations.ts (a SimState
|
|
* round-trips to a V1Payload via the application layer).
|
|
*
|
|
* Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02)
|
|
* fleshes them out with real interfaces in src/sim/garden/types.ts.
|
|
*/
|
|
export interface SimState {
|
|
garden: { tiles: unknown[] };
|
|
plants: unknown[];
|
|
harvestedFragmentIds: string[];
|
|
lastTickAt: number;
|
|
unlockedPlantTypes: string[];
|
|
luraBeatProgress: {
|
|
arrived: boolean;
|
|
mid: boolean;
|
|
farewell: boolean;
|
|
pending: 'arrival' | 'mid' | 'farewell' | null;
|
|
};
|
|
offlineEvents: unknown | null;
|
|
settings: {
|
|
musicVolume: number;
|
|
ambientVolume: number;
|
|
sfxVolume: number;
|
|
persistenceToastShown: boolean;
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 15 — `src/sim/index.ts`** — top-level sim barrel re-exporting from sub-barrels (numbers, scheduler) and SimState type.
|
|
|
|
**Commit:** `feat(02-01): BigQty + scheduler + sim foundations`. Run `npm run lint && npm test` and ensure both pass before committing.
|
|
</action>
|
|
<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>
|
|
<verify>
|
|
<automated>npm run lint && npx vitest run src/sim/numbers/ src/sim/scheduler/ && npm run build</automated>
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Zustand store (4 slices + sim adapter + selectors), V1Payload extension, save lifecycle hooks, EventBus</name>
|
|
<read_first>
|
|
- .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)
|
|
</read_first>
|
|
<files>
|
|
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
|
|
</files>
|
|
<action>
|
|
**Step 1 — Extend `V1Payload` in `src/save/migrations.ts`** (per RESEARCH Pattern 7 lines 847-895 + PATTERNS Group K).
|
|
|
|
Replace the `V1Payload` interface with:
|
|
|
|
```typescript
|
|
/**
|
|
* v1 save shape — Phase-2-extended per CONTEXT D-34.
|
|
*
|
|
* NOTE: This is an EXTENSION, not a migration. Phase 1's v1 has shipped
|
|
* no production saves; Phase 2 adds fields with sensible defaults rather
|
|
* than introducing migrations[2]. The first real v1→v2 migration lands
|
|
* in Phase 4 (Roothold / prestige state).
|
|
*
|
|
* Cross-references:
|
|
* - unlockedPlantTypes → CONTEXT D-05 (plant-type unlocks via fragment count)
|
|
* - luraBeatProgress → CONTEXT D-13 / D-14 (3 beats: arrival / mid / farewell)
|
|
* - offlineEvents → CONTEXT D-19 (offline event log feeding the letter)
|
|
* - settings.persistenceToastShown → CONTEXT D-30 (one-time soft toast)
|
|
*/
|
|
export interface V1Payload {
|
|
garden: { tiles: unknown[] };
|
|
plants: unknown[];
|
|
harvestedFragmentIds: string[];
|
|
lastTickAt: number;
|
|
|
|
// NEW Phase 2 fields:
|
|
unlockedPlantTypes: string[];
|
|
luraBeatProgress: {
|
|
arrived: boolean;
|
|
mid: boolean;
|
|
farewell: boolean;
|
|
pending: 'arrival' | 'mid' | 'farewell' | null;
|
|
};
|
|
offlineEvents: OfflineEventBlock | null;
|
|
|
|
settings: {
|
|
musicVolume: number;
|
|
ambientVolume: number;
|
|
sfxVolume: number;
|
|
persistenceToastShown: boolean;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Local mirror of the OfflineEventBlock shape — declared HERE rather
|
|
* than imported from src/sim/offline/ so the save layer remains a leaf
|
|
* with no upward dependency on sim. The Zod schema lives in src/sim/offline/
|
|
* (Plan 02-05); structural compatibility is enforced via TypeScript at the
|
|
* application boundary (src/store/sim-adapter.ts).
|
|
*/
|
|
export interface OfflineEventBlock {
|
|
plantsBloomedCount: Record<string, number>;
|
|
harvestedFragmentIds: string[];
|
|
luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
|
|
}
|
|
```
|
|
|
|
Update `migrations[1]` body to populate the new defaults:
|
|
|
|
```typescript
|
|
1: (s: unknown): V1Payload => {
|
|
const v0 = (s ?? {}) as V0Payload;
|
|
return {
|
|
garden: { tiles: v0.garden ?? [] },
|
|
plants: [],
|
|
harvestedFragmentIds: [],
|
|
lastTickAt: Date.now(),
|
|
unlockedPlantTypes: [],
|
|
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
|
|
offlineEvents: null,
|
|
settings: {
|
|
musicVolume: 0.7,
|
|
ambientVolume: 0.5,
|
|
sfxVolume: 0.8,
|
|
persistenceToastShown: false,
|
|
},
|
|
};
|
|
},
|
|
```
|
|
|
|
`CURRENT_SCHEMA_VERSION` stays at `1`. Do NOT add `migrations[2]`.
|
|
|
|
**Step 2 — Update `src/save/migrations.test.ts`:**
|
|
|
|
Existing v0→v1 test still asserts the migration runs; ADD assertions for new fields. Use `expect.objectContaining` or precise equality:
|
|
- `migrations[1]({garden: ['x']}).unlockedPlantTypes` deep-equals `[]`.
|
|
- `migrations[1]({...}).luraBeatProgress` deep-equals `{arrived: false, mid: false, farewell: false, pending: null}`.
|
|
- `migrations[1]({...}).offlineEvents` is `null`.
|
|
- `migrations[1]({...}).settings.persistenceToastShown` is `false`.
|
|
- `migrations[1]({...}).settings.musicVolume` is `0.7` (existing value preserved).
|
|
|
|
Also add a regression-defense test: `expect(Object.keys(migrations).sort()).toEqual(['1'])` — proves no `migrations[2]` was sneakily added.
|
|
|
|
**Step 3 — Author Zustand store + 4 slices.**
|
|
|
|
Each slice file (`src/store/garden-slice.ts`, `memory-slice.ts`, `narrative-slice.ts`, `session-slice.ts`) exports:
|
|
- A `*Slice` interface (state fields + setter actions for queueing commands).
|
|
- A `create*Slice` factory that takes Zustand's `set` and `get` and returns the slice object.
|
|
|
|
**`src/store/garden-slice.ts`** (placeholder shapes; Wave 1 plans extend):
|
|
```typescript
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
/**
|
|
* GardenSlice — Phase 2 garden state surface (D-01 through D-07).
|
|
* The 16 tiles + unlocked plant types + queued commands. Wave-1 Plan 02-02
|
|
* (Begin/Plant/Grow) and Plan 02-03 (Harvest/Journal) flesh out the tile
|
|
* data; Wave 0 ships the slice shape so React can subscribe immediately.
|
|
*/
|
|
export interface GardenCommand {
|
|
kind: 'plantSeed' | 'harvest' | 'compost';
|
|
tileIdx: number;
|
|
plantTypeId?: string; // only for plantSeed
|
|
}
|
|
|
|
export interface GardenSlice {
|
|
tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface
|
|
unlockedPlantTypes: string[];
|
|
pendingCommands: GardenCommand[];
|
|
enqueueCommand: (cmd: GardenCommand) => void;
|
|
drainCommands: () => GardenCommand[];
|
|
applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void;
|
|
}
|
|
|
|
export const createGardenSlice: StateCreator<GardenSlice, [], [], GardenSlice> = (set, get) => ({
|
|
tiles: new Array(16).fill(null),
|
|
unlockedPlantTypes: [],
|
|
pendingCommands: [],
|
|
enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })),
|
|
drainCommands: () => {
|
|
const cmds = get().pendingCommands;
|
|
set({ pendingCommands: [] });
|
|
return cmds;
|
|
},
|
|
applyTilesAndUnlocks: (tiles, unlocked) => set({ tiles, unlockedPlantTypes: unlocked }),
|
|
});
|
|
```
|
|
|
|
**`src/store/memory-slice.ts`**:
|
|
```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<MemorySlice, [], [], MemorySlice> = (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<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`**:
|
|
```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<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):
|
|
```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<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.
|
|
```typescript
|
|
import { appStore } from './store';
|
|
import type { GardenCommand } from './garden-slice';
|
|
|
|
/**
|
|
* simAdapter — the application-layer boundary between the pure sim and
|
|
* the Zustand store. The Phaser scene's update() loop calls these:
|
|
* 1. drainCommands() — pull pending commands the React UI enqueued
|
|
* 2. (run scheduler with those commands; receive next state + events)
|
|
* 3. applySimResult(next, events) — write the result back into the store
|
|
*
|
|
* src/sim/ MUST NOT import this file. The CORE-10 firewall (sim → ui)
|
|
* already prevents that; this comment is a reader-facing reminder.
|
|
*/
|
|
export const simAdapter = {
|
|
drainCommands(): GardenCommand[] {
|
|
return appStore.getState().drainCommands();
|
|
},
|
|
applyTilesAndUnlocks(tiles: unknown[], unlocked: string[]): void {
|
|
appStore.getState().applyTilesAndUnlocks(tiles, unlocked);
|
|
},
|
|
applyHarvestedFragments(ids: string[]): void {
|
|
appStore.getState().setHarvested(ids);
|
|
},
|
|
applyLuraProgress(p: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null }): void {
|
|
appStore.getState().setLuraBeatProgress(p);
|
|
},
|
|
};
|
|
```
|
|
|
|
**`src/store/selectors.ts`** — small named selectors React components can use:
|
|
```typescript
|
|
import type { AppStoreShape } from './store';
|
|
|
|
export const selectHarvestCount = (s: AppStoreShape): number => s.harvestedFragmentIds.length;
|
|
export const selectJournalRevealed = (s: AppStoreShape): boolean => s.harvestedFragmentIds.length > 0;
|
|
export const selectBeginGateActive = (s: AppStoreShape): boolean => !s.beginGateDismissed;
|
|
export const selectLuraPending = (s: AppStoreShape) => s.luraBeatProgress.pending;
|
|
```
|
|
|
|
**`src/store/index.ts`** — barrel:
|
|
```typescript
|
|
export { appStore, useAppStore } from './store';
|
|
export type { AppStoreShape } from './store';
|
|
export { simAdapter } from './sim-adapter';
|
|
export type { GardenSlice, GardenCommand } from './garden-slice';
|
|
export type { MemorySlice } from './memory-slice';
|
|
export type { NarrativeSlice, LuraBeatId } from './narrative-slice';
|
|
export type { SessionSlice } from './session-slice';
|
|
export * from './selectors';
|
|
```
|
|
|
|
**Step 4 — `src/store/store.test.ts`** — Vitest:
|
|
- Slice composition: `appStore.getState()` has all four slice keys (`pendingCommands`, `harvestedFragmentIds`, `luraBeatProgress`, `beginGateDismissed`).
|
|
- Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`.
|
|
- React hook surface: `renderHook(() => useAppStore(s => s.harvestedFragmentIds.length))` from `@testing-library/react` re-renders when `setHarvested(['season1.soil.x'])` fires. NOTE: `@testing-library/react` is NOT installed yet — install it as a devDep before writing this part of the test (`npm install -D @testing-library/react`). Confirm `package.json` reflects the install.
|
|
- Selector check: `selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})` returns `true`.
|
|
|
|
**Step 5 — `src/save/lifecycle.ts`** — UX-10 hook implementation (RESEARCH Pitfall 7 lines 1094-1100):
|
|
|
|
```typescript
|
|
/**
|
|
* Save lifecycle hooks (UX-10).
|
|
*
|
|
* Saves fire on:
|
|
* 1. visibilitychange → hidden
|
|
* 2. beforeunload
|
|
* 3. saveOnSeasonTransition() (callable from Phase 4+; Phase 2 verifies via unit test only)
|
|
*
|
|
* The visibilitychange + beforeunload handlers MUST be synchronous (no
|
|
* `await`) — RESEARCH Pitfall 7 line 1094: React unmounts asynchronously
|
|
* and `beforeunload` will not await. The synchronous LocalStorageDBAdapter
|
|
* write path is used here; idb writes are best-effort.
|
|
*/
|
|
|
|
export interface LifecycleHooksHandle {
|
|
/** Detach all listeners. Call from a useEffect cleanup function. */
|
|
detach(): void;
|
|
}
|
|
|
|
export interface LifecycleHooksConfig {
|
|
/** Synchronous serializer that writes to LocalStorage and best-effort to IDB. */
|
|
saveSync: () => void;
|
|
}
|
|
|
|
export function registerSaveLifecycleHooks(config: LifecycleHooksConfig): LifecycleHooksHandle {
|
|
const onVisibility = () => {
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
config.saveSync();
|
|
}
|
|
};
|
|
const onBeforeUnload = () => {
|
|
config.saveSync();
|
|
};
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
}
|
|
return {
|
|
detach() {
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Phase-4+ hook for Season transitions. Phase 2 has no transitions; this
|
|
* function is exported so Phase 4's prestige plan can call it directly
|
|
* (UX-10 third trigger).
|
|
*/
|
|
export function saveOnSeasonTransition(saveSync: () => void): void {
|
|
saveSync();
|
|
}
|
|
```
|
|
|
|
**Step 6 — `src/save/lifecycle.test.ts`** — Vitest with happy-dom:
|
|
- A spy `saveSync` is invoked when `document.dispatchEvent(new Event('visibilitychange'))` fires AND `document.visibilityState === 'hidden'` (use `Object.defineProperty(document, 'visibilityState', {value: 'hidden', configurable: true})`).
|
|
- `saveSync` is invoked when `window.dispatchEvent(new Event('beforeunload'))` fires.
|
|
- `saveOnSeasonTransition(spy)` invokes `spy` exactly once.
|
|
- `handle.detach()` removes both listeners (subsequent dispatches do not invoke the spy).
|
|
|
|
**Step 7 — `src/save/index.ts`** — extend the existing barrel:
|
|
```typescript
|
|
// (existing exports stay)
|
|
export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle';
|
|
export type { LifecycleHooksHandle, LifecycleHooksConfig } from './lifecycle';
|
|
export type { OfflineEventBlock } from './migrations';
|
|
```
|
|
|
|
**Step 8 — `src/game/event-bus.ts`** — Phaser EventBus singleton (RESEARCH Pattern 3 lines 681-694):
|
|
|
|
```typescript
|
|
import * as Phaser from 'phaser';
|
|
|
|
/**
|
|
* Single shared emitter — the Phaser 4 React-template pattern.
|
|
* Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future
|
|
*
|
|
* Used for transient signals between Phaser scenes and React UI:
|
|
* 'scene-ready' (Phaser → React) signals scene tree is live
|
|
* 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker (Plan 02-02)
|
|
* 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal (Plan 02-03)
|
|
*
|
|
* Persistent state lives in src/store/, NOT here. Anti-pattern: routing
|
|
* user-input intents through this bus — those are commands, store-bound.
|
|
*/
|
|
export const eventBus = new Phaser.Events.EventEmitter();
|
|
```
|
|
|
|
(No test for this — it's a single-line singleton; trivial verification via `import { eventBus } from './event-bus'` working in any other file's test suite.)
|
|
|
|
**Commit:** `feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks`. Run `npm run lint && npm test` before committing.
|
|
</action>
|
|
<acceptance_criteria>
|
|
- `grep -q "OfflineEventBlock" src/save/migrations.ts` (new field type declared inline)
|
|
- `grep -q "luraBeatProgress" src/save/migrations.ts`
|
|
- `grep -q "persistenceToastShown" src/save/migrations.ts`
|
|
- `grep -c "^ [0-9]:" src/save/migrations.ts` reports `1` exactly (only `migrations[1]`; no `migrations[2]`)
|
|
- `grep -q "CURRENT_SCHEMA_VERSION = 1" src/save/migrations.ts` (version stays 1)
|
|
- `grep -q "import { createStore } from 'zustand/vanilla'" src/store/store.ts`
|
|
- `grep -q "export const appStore" src/store/store.ts`
|
|
- `grep -q "export const simAdapter" src/store/sim-adapter.ts`
|
|
- `grep -q "registerSaveLifecycleHooks" src/save/lifecycle.ts`
|
|
- `grep -q "saveOnSeasonTransition" src/save/lifecycle.ts`
|
|
- `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added)
|
|
- `grep -q "new Phaser.Events.EventEmitter" src/game/event-bus.ts`
|
|
- `npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts` exits 0 with all tests green
|
|
- `npm run ci` exits 0
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>npm run lint && npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts && npm run ci</automated>
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: ESLint sim-purity rule (Date.now + setInterval ban) with deliberate-violation fixture</name>
|
|
<read_first>
|
|
- .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)
|
|
</read_first>
|
|
<files>
|
|
eslint.config.js,
|
|
src/sim/__test_violation__/date-now-violator.ts,
|
|
src/sim/__test_violation__/lint-firewall.test.ts
|
|
</files>
|
|
<action>
|
|
**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.
|
|
</action>
|
|
<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>
|
|
<verify>
|
|
<automated>npm run lint && npx vitest run src/sim/__test_violation__/ && npm run ci</automated>
|
|
</verify>
|
|
<done>
|
|
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.)
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
|
|
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.
|
|
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
|
|
Plan 02-01 is complete when:
|
|
|
|
- [ ] All 3 tasks committed with conventional-commit messages prefixed with `feat(02-01):` or `chore(02-01):`.
|
|
- [ ] `npm run ci` exits 0.
|
|
- [ ] BigQty wraps `break_eternity.js` and round-trips via toJSON/fromJSON.
|
|
- [ ] formatHumanReadable handles all UX-11 thresholds.
|
|
- [ ] FakeClock + wallClock + drainTicks + computeOfflineCatchup all behave per CORE-02 / CORE-03 / CORE-11.
|
|
- [ ] Zustand vanilla createStore composes 4 slices; useAppStore React hook works in Vitest.
|
|
- [ ] simAdapter exposes drainCommands + applyTilesAndUnlocks + applyHarvestedFragments + applyLuraProgress.
|
|
- [ ] V1Payload extended with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown; migrations[1] populates all new defaults; CURRENT_SCHEMA_VERSION stays at 1; no migrations[2].
|
|
- [ ] registerSaveLifecycleHooks fires saveSync on visibilitychange→hidden, beforeunload, and saveOnSeasonTransition() (UX-10).
|
|
- [ ] Phaser EventBus singleton exported from src/game/event-bus.ts.
|
|
- [ ] ESLint sim-purity rule banning Date.now() and setInterval inside src/sim/** (except clock.ts) lands with deliberate-violation fixture proving the rule fires.
|
|
- [ ] All Wave 1 + Wave 2 plans can begin execution against this foundation.
|
|
|
|
</success_criteria>
|
|
|
|
<output>
|
|
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).
|
|
</output>
|