5bc98ba4ac
54 file targets classified (49 new, 5 modified) with 87% analog coverage. Key patterns: V1Payload extension (not v1→v2 migration), per-layer public barrel pattern, test colocation, Zustand vanilla store + Phaser EventBus singleton as the dual sim↔React bridge, ESLint sim-purity rule proposed as a defended option (not auto-locked, per minimum-viable bias). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1027 lines
53 KiB
Markdown
1027 lines
53 KiB
Markdown
# Phase 2: Season 1 Vertical Slice (Soil) — Pattern Map
|
|
|
|
**Mapped:** 2026-05-09
|
|
**Files analyzed:** 49 new + 5 modified (54 total)
|
|
**Analogs found:** 47 / 54 (87%)
|
|
|
|
## Reading order for the planner
|
|
|
|
1. **Architectural firewall is non-negotiable.** ESLint enforces `src/sim/` cannot import from `src/render/` or `src/ui/`. Every plan's first acceptance criterion is `npm run ci` green. The firewall test under `src/sim/__test_violation__/` is the proof-of-rule; don't break it.
|
|
2. **Phase 1 already shipped the heavy infrastructure.** Save layer (`src/save/`) and content pipeline (`src/content/`) are *frozen public barrels* — Phase 2 imports from `src/save/index.ts` and `src/content/index.ts`, never from internal modules. The only edit inside `src/save/` is to `migrations.ts` (extend `V1Payload`); the only addition inside `src/content/` is `ink-loader.ts`.
|
|
3. **`V1Payload` is extended, not migrated.** `CURRENT_SCHEMA_VERSION` stays at `1`. No new `migrations[2]` entry. Phase 4 owns `migrations[2]`.
|
|
4. **Sim is pure.** No `Date.now()`, no `setInterval`, no DOM, no fetch. Time is *injected* via the scheduler. The scheduler at `src/sim/scheduler/clock.ts` is the only file in the project allowed to call `Date.now()`.
|
|
5. **Tests live next to the file under test.** `foo.ts` ↔ `foo.test.ts`. Vitest config already includes `src/**/*.test.ts` and `src/**/*.test.tsx`.
|
|
|
|
## File Classification
|
|
|
|
### NEW files (49)
|
|
|
|
| New file | Role | Data flow | Closest analog | Match quality |
|
|
|----------|------|-----------|----------------|---------------|
|
|
| `src/sim/numbers/big-qty.ts` | sim/utility | transform (immutable value class) | `src/save/checksum.ts` (small pure utility wrapping a 3rd-party lib) | role-match |
|
|
| `src/sim/numbers/big-qty.test.ts` | test | unit | `src/save/checksum.test.ts` | exact |
|
|
| `src/sim/numbers/format.ts` | sim/utility | transform (number → display string) | `src/save/checksum.ts` (canonicalJSON) | role-match |
|
|
| `src/sim/numbers/format.test.ts` | test | unit | `src/save/checksum.test.ts` (canonicalJSON cases) | exact |
|
|
| `src/sim/numbers/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/scheduler/clock.ts` | sim/service | wall-time injection point | none — first sim module | no analog |
|
|
| `src/sim/scheduler/clock.test.ts` | test | unit | `src/save/checksum.test.ts` | role-match |
|
|
| `src/sim/scheduler/tick.ts` | sim/service | accumulator drain (pure) | none — first scheduler | no analog |
|
|
| `src/sim/scheduler/tick.test.ts` | test | unit | `src/save/migrations.test.ts` (pure-function pipeline) | role-match |
|
|
| `src/sim/scheduler/catchup.ts` | sim/service | offline replay (pure) | none — first catch-up | no analog |
|
|
| `src/sim/scheduler/catchup.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match |
|
|
| `src/sim/scheduler/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/garden/types.ts` | sim/model | data shape declarations | `src/save/migrations.ts` (V1Payload interface) | role-match |
|
|
| `src/sim/garden/plants.ts` | sim/data | static plant-type table | `src/content/schemas/fragment.ts` (static schema declaration) | role-match |
|
|
| `src/sim/garden/growth.ts` | sim/service | state machine (pure) | none — first state machine | no analog |
|
|
| `src/sim/garden/growth.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match |
|
|
| `src/sim/garden/commands.ts` | sim/service | command application (pure) | none — first command set | no analog |
|
|
| `src/sim/garden/commands.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match |
|
|
| `src/sim/garden/auto-harvest.ts` | sim/service | offline event aggregation | none — first offline | no analog |
|
|
| `src/sim/garden/auto-harvest.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match |
|
|
| `src/sim/garden/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/memory/selector.ts` | sim/service | deterministic pool selection (pure) | `src/content/loader.ts` (filter+validate over loaded data) | role-match |
|
|
| `src/sim/memory/selector.test.ts` | test | unit | `src/content/loader.test.ts` (gates+rejects) | exact |
|
|
| `src/sim/memory/pool.ts` | sim/service | content adapter | `src/content/loader.ts` | role-match |
|
|
| `src/sim/memory/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/narrative/lura-gate.ts` | sim/service | tick-count gate (pure) | `src/save/migrations.ts` (registry-keyed dispatch) | partial |
|
|
| `src/sim/narrative/lura-gate.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match |
|
|
| `src/sim/narrative/beat-queue.ts` | sim/model | queue shape | `src/save/migrations.ts` (V1Payload contract) | role-match |
|
|
| `src/sim/narrative/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/offline/events.ts` | sim/model+validator | Zod schema + aggregator | `src/content/schemas/fragment.ts` | role-match |
|
|
| `src/sim/offline/events.test.ts` | test | unit | `src/content/loader.test.ts` (zod rejection cases) | role-match |
|
|
| `src/sim/offline/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/sim/state.ts` | sim/model | root SimState shape | `src/save/migrations.ts` (V1Payload root) | exact |
|
|
| `src/sim/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/store/store.ts` | store/composition | Zustand vanilla store + slices | none — first store | no analog |
|
|
| `src/store/garden-slice.ts` | store/slice | command queue + tile state | none — first slice | no analog |
|
|
| `src/store/memory-slice.ts` | store/slice | fragment + reveal-modal state | none — first slice | no analog |
|
|
| `src/store/narrative-slice.ts` | store/slice | beat queue + dialogue state | none — first slice | no analog |
|
|
| `src/store/session-slice.ts` | store/slice | begin gate + toast state | none — first slice | no analog |
|
|
| `src/store/selectors.ts` | store/utility | derived view selectors | none — first selectors | no analog |
|
|
| `src/store/store.test.ts` | test | integration | `src/save/round-trip.test.ts` (composition) | partial |
|
|
| `src/store/index.ts` | store/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/render/garden/tile-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog |
|
|
| `src/render/garden/plant-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog |
|
|
| `src/render/garden/ready-pulse.ts` | render/phaser | canvas alpha animation | none — first render | no analog |
|
|
| `src/render/garden/gate-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog |
|
|
| `src/render/garden/tile-coords.ts` | render/utility | tile↔screen coordinate map | none — first coord helper | no analog |
|
|
| `src/render/garden/index.ts` | render/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/render/index.ts` | render/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/begin/BeginScreen.tsx` | ui/component | DOM full-screen modal + gesture | `src/App.tsx` (root JSX layout) | partial |
|
|
| `src/ui/begin/BeginScreen.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/begin/use-audio-bootstrap.ts` | ui/hook | gesture-gated AudioContext | none — first audio hook | no analog |
|
|
| `src/ui/begin/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/journal/Journal.tsx` | ui/component | DOM full-screen modal | `src/App.tsx` | partial |
|
|
| `src/ui/journal/Journal.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/journal/FragmentRevealModal.tsx` | ui/component | DOM modal | `src/App.tsx` | partial |
|
|
| `src/ui/journal/FragmentRevealModal.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/journal/journal-icon.tsx` | ui/component | DOM icon button | `src/App.tsx` | partial |
|
|
| `src/ui/journal/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/letter/Letter.tsx` | ui/component | DOM full-screen modal | `src/App.tsx` | partial |
|
|
| `src/ui/letter/Letter.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/letter/letter-renderer.ts` | ui/utility | inkjs Story driver | none — first ink runtime | no analog |
|
|
| `src/ui/letter/letter-renderer.test.ts` | test | unit | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/letter/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/dialogue/LuraDialogue.tsx` | ui/component | DOM dialogue overlay | `src/App.tsx` | partial |
|
|
| `src/ui/dialogue/LuraDialogue.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/dialogue/ink-renderer.tsx` | ui/component | text-cadence drip | none — first cadence | no analog |
|
|
| `src/ui/dialogue/ink-runtime.ts` | ui/utility | inkjs Story instantiation | none — first ink runtime | no analog |
|
|
| `src/ui/dialogue/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/settings/Settings.tsx` | ui/component | DOM modal (Export/Import/Restore) | `src/App.tsx` | partial |
|
|
| `src/ui/settings/Settings.test.tsx` | test | component | `src/save/round-trip.test.ts` (export/import flows) | partial |
|
|
| `src/ui/settings/persistence-toast.tsx` | ui/component | DOM transient toast | none — first toast | no analog |
|
|
| `src/ui/settings/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/garden/SeedPicker.tsx` | ui/component | DOM popover over canvas | none — first popover | no analog |
|
|
| `src/ui/garden/SeedPicker.test.tsx` | test | component | `src/content/loader.test.ts` | partial |
|
|
| `src/ui/garden/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/ui/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact |
|
|
| `src/game/event-bus.ts` | game/singleton | Phaser EventEmitter singleton | none — first event bus | no analog |
|
|
| `src/game/scenes/Garden.ts` | game/scene | canvas scene with input + tick drive | `src/game/scenes/Boot.ts` | exact |
|
|
| `src/game/scenes/Preloader.ts` (optional) | game/scene | asset load | `src/game/scenes/Boot.ts` | exact |
|
|
| `src/content/ink-loader.ts` | content/loader | runtime JSON load + variable bind | `src/content/loader.ts` | role-match |
|
|
| `src/content/ink-loader.test.ts` | test | unit | `src/content/loader.test.ts` | exact |
|
|
| `scripts/compile-ink.mjs` | build/script | inklecate batch compile | `scripts/validate-assets.mjs` | role-match |
|
|
| `scripts/check-bundle-split.mjs` | build/script | structural assertion on dist/ | `scripts/validate-assets.mjs` | role-match |
|
|
| `tests/e2e/season1-loop.spec.ts` | test/e2e | Playwright full-loop smoke | none — first e2e | no analog (only `playwright.config.ts` exists) |
|
|
|
|
### MODIFIED files (5)
|
|
|
|
| Modified file | Edit kind | Existing role |
|
|
|---------------|-----------|---------------|
|
|
| `src/save/migrations.ts` | extend `V1Payload` interface + extend `migrations[1]` defaults | save/model |
|
|
| `src/save/migrations.test.ts` | add cases asserting new fields default correctly | test |
|
|
| `src/content/loader.ts` | swap to `eager: false` lazy variant for Season-1 (PIPE-02) | content/loader |
|
|
| `src/game/main.ts` | add `Garden` (and optional `Preloader`) to `scene: []` | game/config |
|
|
| `src/game/scenes/Boot.ts` | replace placeholder `create()` body with `this.scene.start('Garden')` | game/scene |
|
|
| `src/PhaserGame.tsx` | subscribe to event-bus; wire save lifecycle hooks (visibilitychange/beforeunload) | app/bridge |
|
|
| `src/App.tsx` | mount Begin/Journal/Settings/Letter/Dialogue/SeedPicker as siblings to `<PhaserGame>` | app/root |
|
|
| `package.json` | replace `compile:ink` no-op with `node scripts/compile-ink.mjs`; add `check:bundle-split` to `ci` | config |
|
|
| `eslint.config.js` | (recommended) add `no-restricted-syntax` rule banning `Date.now()` inside `src/sim/**` except `src/sim/scheduler/clock.ts` | config |
|
|
| `content/seasons/00-demo/fragments.yaml` | DELETE (replaced by Season 1) | content |
|
|
|
|
## Pattern Assignments
|
|
|
|
### Group A — sim/numbers (BigQty + format) [Wave 0, Plan 02-01]
|
|
|
|
**Analog:** `src/save/checksum.ts` — a small, pure, immutable, third-party-wrapping module with colocated test.
|
|
|
|
**Imports pattern** (`src/save/checksum.ts:1-1` + `src/save/checksum.test.ts:1-2`):
|
|
```typescript
|
|
// production module
|
|
import CRC32 from 'crc-32';
|
|
// test
|
|
import { describe, it, expect } from 'vitest';
|
|
import { crc32hex, canonicalJSON } from './checksum';
|
|
```
|
|
|
|
**Comment-style discipline** (`src/save/checksum.ts:3-8`):
|
|
```typescript
|
|
/**
|
|
* 8-char lowercase hex CRC-32 of the input string.
|
|
* crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
|
|
* Used by envelope.wrap/unwrap to detect save corruption (lossy storage,
|
|
* partial writes, browser-eviction truncation).
|
|
*/
|
|
```
|
|
Phase 1 modules ship a leading docblock that names the requirement (CORE-XX, Pitfall N, etc.) the function defends. Every Phase-2 sim module should do the same — link to the relevant CONTEXT decision (D-31, D-33, …) or RESEARCH pattern (Pattern 1, Pattern 2, …).
|
|
|
|
**Test pattern** (`src/save/checksum.test.ts:7-19`):
|
|
```typescript
|
|
describe('crc32hex', () => {
|
|
it('is deterministic — same input always returns same output', () => {
|
|
expect(crc32hex('hello')).toBe(crc32hex('hello'));
|
|
});
|
|
|
|
it('returns 8-char lowercase hex', () => {
|
|
expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/);
|
|
});
|
|
|
|
it('differs for different inputs', () => {
|
|
expect(crc32hex('hello')).not.toBe(crc32hex('world'));
|
|
});
|
|
});
|
|
```
|
|
Tests are colocated, use one `describe` per exported symbol, and each `it` asserts one invariant. The Phase-2 BigQty test file should follow exactly this layout (one `describe` for `BigQty`, one each for `add` / `mul` / `eq` / `toJSON` / `fromJSON` / `format`).
|
|
|
|
**Concrete code prescription** for `big-qty.ts` and `format.ts` is in RESEARCH.md Pattern 2 (lines 547-600). Copy the class skeleton from there; copy the docblock-with-citation style from `checksum.ts`.
|
|
|
|
**Boundary callout:** `src/sim/numbers/` is sim — ESLint will reject any import of `src/render/`, `src/ui/`. Imports allowed: `break_eternity.js` only.
|
|
|
|
---
|
|
|
|
### Group B — sim/scheduler (clock + tick + catchup) [Wave 0, Plan 02-01]
|
|
|
|
**Analog:** `src/save/checksum.ts` (pure utilities) for shape; **no behavioral analog** — first scheduler in the project.
|
|
|
|
**Imports pattern** is the same as Group A. The `clock.ts` module is the *only* file in the entire project allowed to import `Date.now()`. Add the following docblock at the top:
|
|
```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 recommended ESLint no-restricted-syntax rule
|
|
* (RESEARCH Pitfall 1, line 1037) excludes this file specifically.
|
|
*/
|
|
```
|
|
|
|
**Production class skeleton** (RESEARCH lines 502-521):
|
|
```typescript
|
|
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; }
|
|
}
|
|
```
|
|
|
|
**Tick / catchup skeleton** (RESEARCH lines 455-493): copy the `drainTicks()` accumulator pattern. Implements CORE-02 + CORE-03 + CORE-11 (refuses negative; clamps 24h).
|
|
|
|
**Test pattern** for catchup mirrors `src/save/migrations.test.ts:45-51`:
|
|
```typescript
|
|
it('throws when fromVersion is in the future (no migration registered)', () => {
|
|
expect(() => migrate({}, 99)).toThrow();
|
|
});
|
|
|
|
it('throws when fromVersion is negative', () => {
|
|
expect(() => migrate({}, -1)).toThrow();
|
|
});
|
|
```
|
|
For Phase 2's catchup: assert `drainTicks(state, -1)` returns the original state with `ticksApplied: 0`, and `drainTicks(state, 25 * 3600 * 1000)` clamps to exactly `Math.floor(24 * 3600 * 1000 / TICK_MS)` ticks.
|
|
|
|
**Boundary callout:** `src/sim/scheduler/` is sim. The `Clock` interface is consumed by every other `src/sim/` module via dependency injection — never import `wallClock` directly from sim modules; the scheduler injects the chosen clock at the boundary in `src/store/` or `src/game/scenes/Garden.ts`.
|
|
|
|
---
|
|
|
|
### Group C — sim/garden (types + plants + growth + commands + auto-harvest) [Wave 1, Plan 02-02]
|
|
|
|
**Analog:** `src/save/migrations.ts` (V1Payload model + pure transforms).
|
|
|
|
**Type-declaration pattern** (`src/save/migrations.ts:23-38`):
|
|
```typescript
|
|
/**
|
|
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
|
|
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
|
|
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
|
|
*/
|
|
export interface V1Payload {
|
|
garden: { tiles: unknown[] };
|
|
plants: unknown[];
|
|
harvestedFragmentIds: string[];
|
|
lastTickAt: number;
|
|
settings: {
|
|
musicVolume: number;
|
|
ambientVolume: number;
|
|
sfxVolume: number;
|
|
};
|
|
}
|
|
```
|
|
Phase 2's `src/sim/garden/types.ts` exports `Tile`, `PlantInstance`, `PlantType`, `PlantTypeId`, `GardenCommand` with the same shape — small, plain TypeScript interfaces, no methods, no validation. Validation lives separately (Group F: `src/sim/offline/events.ts` ships the Zod schema for `OfflineEventBlock`; sim types stay structural).
|
|
|
|
**Pure-function pattern** (`src/save/migrations.ts:48-63` — the registry of pure transforms):
|
|
```typescript
|
|
export const migrations: Record<number, Migration> = {
|
|
1: (s: unknown): V1Payload => {
|
|
const v0 = (s ?? {}) as V0Payload;
|
|
return {
|
|
garden: { tiles: v0.garden ?? [] },
|
|
plants: [],
|
|
harvestedFragmentIds: [],
|
|
lastTickAt: Date.now(),
|
|
settings: { ... },
|
|
};
|
|
},
|
|
};
|
|
```
|
|
Phase 2's commands (`plantSeed`, `harvest`, `compost`) follow the same shape: `(state, args) => state`. Pure. No `Date.now()` (the time arg is *injected* by the scheduler). No store reads.
|
|
|
|
**Tile coordinate convention** — RESEARCH Pitfall 2 (line 1042): canonical encoding `index = row * 4 + col`; export `tileIdx(row, col)` and `tileCoords(idx)` helpers; ban raw arithmetic at call sites.
|
|
|
|
**Boundary callout:** `src/sim/garden/` is sim. Cannot import `src/render/`, `src/ui/`, `src/store/`, `src/game/`. Can import `src/sim/scheduler/` (for `Clock` type only — never call `clock.now()` from inside garden code; the application layer threads `now` through as an argument).
|
|
|
|
---
|
|
|
|
### Group D — sim/memory (fragment selector) [Wave 1, Plan 02-03]
|
|
|
|
**Analog:** `src/content/loader.ts` (filtered, validated traversal of authored content).
|
|
|
|
**Filter + validate over loaded data** (`src/content/loader.ts:31-40`):
|
|
```typescript
|
|
function loadYamlFragments(): Fragment[] {
|
|
return Object.entries(yamlFiles).flatMap(([path, raw]) => {
|
|
const data = parseYAML(raw);
|
|
const parsed = SeasonContentSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
|
}
|
|
return parsed.data.fragments;
|
|
});
|
|
}
|
|
```
|
|
The selector at `src/sim/memory/selector.ts` does the same shape: filter the fragment pool by `(season, plantType, alreadyHarvested)`, validate the result is non-empty, return one entry. **But** — sim cannot import `src/content/` directly without going through the public barrel `src/content/index.ts`. RESEARCH Don't Hand-Roll table line 1013: deterministic + no-dup with mulberry32 PRNG (~10 LoC pure).
|
|
|
|
**Test pattern with rejection cases** (`src/content/loader.test.ts:40-50`):
|
|
```typescript
|
|
it('THROWS on a numeric-id violation (stable-string-ID rule)', () => {
|
|
const yamlGlob = {
|
|
'/content/seasons/01-soil/fragments.yaml': `
|
|
fragments:
|
|
- id: 42
|
|
season: 1
|
|
body: "..."
|
|
`,
|
|
};
|
|
expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/);
|
|
});
|
|
```
|
|
Phase 2's `selector.test.ts` should run the same shape: pose a degenerate input (empty pool, exhausted pool, locked plant type), assert the function returns the documented sentinel or throws.
|
|
|
|
**Boundary callout:** `src/sim/memory/` is sim. Imports `src/content/index.ts` (barrel only — never `src/content/loader.ts` directly). Does NOT import `src/render/`, `src/ui/`, `src/store/`.
|
|
|
|
---
|
|
|
|
### Group E — sim/narrative (Lura beat gating) [Wave 2, Plan 02-04]
|
|
|
|
**Analog:** `src/save/migrations.ts:48-63` (registry-keyed dispatch on a known set of integer keys).
|
|
|
|
**Registry pattern** (the migrations registry is the closest shape to a beat dispatcher):
|
|
```typescript
|
|
export const migrations: Record<number, Migration> = {
|
|
1: (s: unknown): V1Payload => { /* ... */ },
|
|
};
|
|
```
|
|
Phase 2's `src/sim/narrative/lura-gate.ts` exports a similar registry keyed on `harvestCount` thresholds (1, 4, 8 per D-14):
|
|
```typescript
|
|
const LURA_BEAT_THRESHOLDS = {
|
|
1: 'arrival',
|
|
4: 'mid',
|
|
8: 'farewell',
|
|
} as const;
|
|
```
|
|
The gate function takes `(prevHarvestCount, nextHarvestCount, beatProgress)` and returns the next pending beat or `null`. **Pure**; no Ink imports (RESEARCH line 27: "reads sim state's harvest count; does NOT load Ink content"). Ink runtime lives in `src/ui/dialogue/`.
|
|
|
|
**STRY-10 test pattern** — tick-count semantics, not wall-time:
|
|
```typescript
|
|
// Modeled after src/save/migrations.test.ts:53-63
|
|
it('FakeClock advance does NOT advance Lura beats without harvest events', () => {
|
|
const clock = new FakeClock(0);
|
|
let state = initialSimState();
|
|
clock.advance(60 * 60 * 1000); // 1 hour
|
|
// No harvests fired — beats must remain at 0
|
|
expect(luraBeatProgress(state)).toEqual({ arrived: false, mid: false, farewell: false, pending: null });
|
|
});
|
|
```
|
|
|
|
**Boundary callout:** `src/sim/narrative/` is sim. Cannot import `inkjs` (Ink runtime is UI tier per Architectural Responsibility Map line 40). Cannot import `src/render/`, `src/ui/`, `src/store/`.
|
|
|
|
---
|
|
|
|
### Group F — sim/offline (event aggregator + Zod schema) [Wave 2, Plan 02-05]
|
|
|
|
**Analog:** `src/content/schemas/fragment.ts` (Zod schema) + `src/content/loader.ts` (aggregator).
|
|
|
|
**Zod schema pattern** (`src/content/schemas/fragment.ts:14-18`):
|
|
```typescript
|
|
export const FragmentSchema = z.object({
|
|
id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
|
|
season: z.number().int().min(0).max(7),
|
|
body: z.string().min(1),
|
|
});
|
|
|
|
export type Fragment = z.infer<typeof FragmentSchema>;
|
|
```
|
|
Phase 2's `OfflineEventBlock` (D-19) follows exactly:
|
|
```typescript
|
|
export const OfflineEventBlockSchema = z.object({
|
|
plantsBloomedCount: z.record(z.string(), z.number().int().nonnegative()),
|
|
harvestedFragmentIds: z.array(z.string().regex(/^season\d+\.[a-z0-9._-]+$/)),
|
|
luraBeatPending: z.enum(['arrival', 'mid', 'farewell']).nullable(),
|
|
});
|
|
|
|
export type OfflineEventBlock = z.infer<typeof OfflineEventBlockSchema>;
|
|
```
|
|
|
|
**Boundary callout:** `src/sim/offline/` is sim. Imports `zod` only.
|
|
|
|
---
|
|
|
|
### Group G — store (Zustand vanilla + slices + selectors) [Wave 0, Plan 02-01]
|
|
|
|
**Analog:** none — first store. Closest shape is the *barrel pattern* of `src/save/index.ts`.
|
|
|
|
**Composition skeleton** — copy directly from RESEARCH.md Pattern 3 (lines 624-661):
|
|
```typescript
|
|
import { createStore } from 'zustand/vanilla';
|
|
import { useStore } from 'zustand';
|
|
|
|
export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice;
|
|
|
|
export const appStore = createStore<AppStoreShape>()((set, get) => ({
|
|
...createGardenSlice(set, get),
|
|
...createMemorySlice(set, get),
|
|
...createNarrativeSlice(set, get),
|
|
...createSessionSlice(set, get),
|
|
}));
|
|
|
|
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T {
|
|
return useStore(appStore, selector);
|
|
}
|
|
```
|
|
|
|
**Barrel pattern** (`src/save/index.ts:1-38`):
|
|
```typescript
|
|
/**
|
|
* Public surface of the save layer. Phase 2's tick scheduler + Zustand
|
|
* store are the first consumers — they should ONLY import from this
|
|
* file, never from the individual modules underneath.
|
|
*/
|
|
|
|
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';
|
|
// …
|
|
```
|
|
`src/store/index.ts` re-exports `appStore`, `useAppStore`, slice types, and the `simAdapter` interface only — never internal slice creators.
|
|
|
|
**Boundary callout:** `src/store/` is its own ESLint element type (declared in `eslint.config.js:87`). The default rule is `allow`, so:
|
|
- `src/sim/` cannot import `src/store/` directly (sim is pure; it returns new state, the application layer applies it via `simAdapter`).
|
|
- `src/render/`, `src/ui/`, `src/game/` MAY import `src/store/index.ts` to read state and enqueue commands.
|
|
|
|
The `simAdapter` lives in `src/store/` (not in `src/sim/`) so the sim never imports the store. RESEARCH lines 651-661.
|
|
|
|
---
|
|
|
|
### Group H — render/garden (Phaser primitive draw) [Wave 1, Plan 02-02]
|
|
|
|
**Analog:** `src/game/scenes/Boot.ts` (current scene shape; expand for tile/plant draws).
|
|
|
|
**Scene shape** (`src/game/scenes/Boot.ts:7-19`):
|
|
```typescript
|
|
export class Boot extends Phaser.Scene {
|
|
constructor() {
|
|
super('Boot');
|
|
}
|
|
|
|
preload(): void {
|
|
// No assets in Phase 1.
|
|
}
|
|
|
|
create(): void {
|
|
// Phase 2 will start the preloader from here.
|
|
}
|
|
}
|
|
```
|
|
Phase 2's `src/game/scenes/Garden.ts` follows. Renderers in `src/render/garden/*.ts` are *not* scenes — they're modules called by the `Garden` scene's `create()` and `update()`. They take a `Phaser.Scene` reference and draw onto it; they don't extend `Phaser.Scene`.
|
|
|
|
**Phaser config registration** (`src/game/main.ts:9-20`):
|
|
```typescript
|
|
const config: Phaser.Types.Core.GameConfig = {
|
|
type: Phaser.AUTO,
|
|
width: 1024,
|
|
height: 768,
|
|
parent: 'game-container',
|
|
backgroundColor: '#1a1a1a',
|
|
scale: {
|
|
mode: Phaser.Scale.FIT,
|
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
|
},
|
|
scene: [Boot],
|
|
};
|
|
```
|
|
Phase 2 modifies this to `scene: [Boot, Garden]` (or `[Boot, Preloader, Garden]`). Boot's `create()` becomes `this.scene.start('Garden')`.
|
|
|
|
**Tile-coords helper** — RESEARCH lines 702-715. The tile coordinate translation is **purely a render concern** — it lives in `src/render/garden/tile-coords.ts`, not in `src/sim/garden/`. The seed picker (UI tier) uses this helper *via* an EventBus event payload, so it never imports `src/render/` directly.
|
|
|
|
**Boundary callout:** `src/render/garden/` is render. ESLint allows `src/render/` to import anywhere by default. But: do not import `src/sim/garden/` from rendering code — the scene reads tile state from the *store* (Zustand), not from sim modules directly. This keeps render independent of sim's internal evolution. (The store types are sufficient.)
|
|
|
|
---
|
|
|
|
### Group I — ui/* (React DOM overlays: Begin / Journal / Letter / Dialogue / Settings / SeedPicker) [Waves 1+2]
|
|
|
|
**Analog:** `src/App.tsx` (only existing React component) — but it's a thin shell. UI components are first of their kind.
|
|
|
|
**Mounting pattern** (`src/App.tsx:1-15`):
|
|
```typescript
|
|
import { useRef } from 'react';
|
|
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
|
|
|
function App() {
|
|
const phaserRef = useRef<IRefPhaserGame | null>(null);
|
|
|
|
return (
|
|
<div id="app">
|
|
<PhaserGame ref={phaserRef} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
```
|
|
Phase 2 expands `App.tsx` to mount overlays as siblings to `<PhaserGame>`:
|
|
```typescript
|
|
return (
|
|
<div id="app">
|
|
<PhaserGame ref={phaserRef} />
|
|
<BeginScreen />
|
|
<SeedPicker />
|
|
<Journal />
|
|
<FragmentRevealModal />
|
|
<LuraDialogue />
|
|
<Letter />
|
|
<Settings />
|
|
<PersistenceToast />
|
|
</div>
|
|
);
|
|
```
|
|
Each overlay is self-gating (reads its own visibility flag from the store via `useAppStore`); `App.tsx` does not branch.
|
|
|
|
**TSX file shape** — there is no Phase-1 React component beyond `App.tsx` and `PhaserGame.tsx`, so the planner should follow the standard React 19 pattern: function components, named export, colocated `*.test.tsx` using `@testing-library/react` (will need to be added to devDependencies — RESEARCH line 207 lists this as a Phase-2 install).
|
|
|
|
**MEMR-05 contract:** Journal text MUST render as DOM (not Phaser canvas) so it is selectable and copy-pasteable. Anti-pattern at RESEARCH line 1003.
|
|
|
|
**Boundary callout:** `src/ui/` is ui. Allowed imports: `react`, `inkjs`, `src/store/index.ts`, `src/save/index.ts`, `src/content/index.ts`. **Forbidden** by ESLint: `src/sim/` (the firewall is `sim cannot import ui`; ui importing sim is also a smell — go through the store). UI components NEVER import `src/render/` or `src/game/` directly — cross-tier signaling goes via the EventBus + store.
|
|
|
|
**Audio bootstrap hook** at `src/ui/begin/use-audio-bootstrap.ts` — copy the exact code from RESEARCH lines 953-987 (Pattern 9). The function MUST be called *synchronously inside* a click handler (RESEARCH Pitfall 5, line 1076) — never inside `useEffect`.
|
|
|
|
---
|
|
|
|
### Group J — content/ink-loader + scripts/compile-ink.mjs [Wave 2]
|
|
|
|
**Analog:** `src/content/loader.ts` (Vite-native glob with Zod validation) + `scripts/validate-assets.mjs` (CI-runnable Node script).
|
|
|
|
**Vite glob pattern** (`src/content/loader.ts:19-29`):
|
|
```typescript
|
|
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
|
|
eager: true,
|
|
query: '?raw',
|
|
import: 'default',
|
|
}) as Record<string, string>;
|
|
|
|
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
|
|
eager: true,
|
|
query: '?raw',
|
|
import: 'default',
|
|
}) as Record<string, string>;
|
|
```
|
|
Phase 2's `src/content/ink-loader.ts` adds a third path for compiled-Ink JSON. Compiled JSON lives under `src/content/compiled-ink/` (gitignored, generated). Use a separate `import.meta.glob` for `'/src/content/compiled-ink/**/*.ink.json'` with `query: 'json'` (or default JSON import — Vite handles `.json` natively). Per RESEARCH line 365.
|
|
|
|
**Schema-violation throw** (`src/content/loader.ts:35-37`):
|
|
```typescript
|
|
if (!parsed.success) {
|
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
|
}
|
|
```
|
|
The Ink loader follows the same shape: throw at module-eval if the compiled JSON is missing/malformed. The throw bubbles up through Vite, exits `npm run build` non-zero — same PIPE-01 contract.
|
|
|
|
**Build-script pattern** (`scripts/validate-assets.mjs` — already exists; structural analog only). `scripts/compile-ink.mjs` is a Node ESM module that shells `inklecate` over each `.ink` file under `/content/dialogue/season1/`. RESEARCH lines 743-800 detail the invocation; Assumption A6 (RESEARCH line 1213+1562) flags that the inklecate Windows-binary invocation needs verification on first run.
|
|
|
|
**Lazy-loading switch** (PIPE-02): RESEARCH Pattern 8 (lines 906-940) details the `eager: false` shift. **Critical:** Phase 1's existing `loader.test.ts` MUST continue to pass after the switch. The test exercises `loadFragmentsFromGlob` (the test-only helper accepting mocked globs); the real `import.meta.glob` is replaced. Add a new test case asserting Season-1 fragments load via the lazy `loadSeasonFragments(1)` path.
|
|
|
|
**Boundary callout:** `src/content/` is content. Imports `gray-matter`, `yaml`, `zod`, `inkjs` (the new ink-loader needs `inkjs.Story`).
|
|
|
|
---
|
|
|
|
### Group K — Save extension (`src/save/migrations.ts`) [Wave 0, Plan 02-01]
|
|
|
|
**Analog:** ITSELF — Phase 2 edits the existing file in place.
|
|
|
|
**Concrete edit** (RESEARCH Pattern 7, lines 847-895). Copy the new `V1Payload` interface verbatim:
|
|
```typescript
|
|
export interface V1Payload {
|
|
garden: { tiles: TileSlot[] };
|
|
plants: PlantInstance[];
|
|
harvestedFragmentIds: string[];
|
|
lastTickAt: number;
|
|
|
|
// NEW Phase 2 fields:
|
|
unlockedPlantTypes: PlantTypeId[];
|
|
luraBeatProgress: {
|
|
arrived: boolean;
|
|
mid: boolean;
|
|
farewell: boolean;
|
|
pending: 'arrival' | 'mid' | 'farewell' | null;
|
|
};
|
|
offlineEvents: OfflineEventBlock | null;
|
|
|
|
settings: {
|
|
musicVolume: number;
|
|
ambientVolume: number;
|
|
sfxVolume: number;
|
|
persistenceToastShown: boolean;
|
|
};
|
|
}
|
|
```
|
|
|
|
**Migration update** (`src/save/migrations.ts:48-63` becomes RESEARCH lines 874-895):
|
|
```typescript
|
|
export const migrations: Record<number, Migration> = {
|
|
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`.** No `migrations[2]`. RESEARCH line 897.
|
|
|
|
**Test edit** (`src/save/migrations.test.ts:14-43`): the existing v0→v1 case becomes more thorough — assert the new fields default correctly. Add a new case verifying the v1→v1 no-op (line 32-43) preserves the new fields when supplied. Use `expect.objectContaining` for forward-compatibility — don't lock to exact equality on a moving shape.
|
|
|
|
**Critical doctrine** (CONTEXT specifics, line 187): "Phase 1's V1Payload has not shipped any production saves; Phase 2 extends the v1 payload shape rather than adding `migrate_v1_to_v2`. The first real migration lands in Phase 4 (per Phase 1 D-04). The synthetic v0→v1 demo migration in `migrations[1]` continues to work as the proof-of-chain."
|
|
|
|
**Boundary callout:** `src/save/` is save. The migration must NOT import from `src/sim/garden/types.ts` — that would create a save → sim dependency. Instead, declare local mirrored interfaces (`TileSlot`, `PlantInstance`, `PlantTypeId`, `OfflineEventBlock`) in `migrations.ts` and rely on TypeScript structural typing at the application boundary. Alternatively, move the shared types to a "primitives" module that both sim and save can import (e.g., `src/save/v1-types.ts` re-exported, with sim mirroring its own copy). RESEARCH does not pre-decide; planner picks one.
|
|
|
|
---
|
|
|
|
### Group L — game/event-bus + Garden scene + main.ts edits [Wave 1, Plan 02-02]
|
|
|
|
**Analog:** `src/game/main.ts` and `src/game/scenes/Boot.ts`.
|
|
|
|
**Scene-list edit** (`src/game/main.ts:19`):
|
|
```typescript
|
|
scene: [Boot],
|
|
```
|
|
becomes:
|
|
```typescript
|
|
scene: [Boot, Garden], // or [Boot, Preloader, Garden]
|
|
```
|
|
|
|
**Boot transition** (`src/game/scenes/Boot.ts:16-18`):
|
|
```typescript
|
|
create(): void {
|
|
// Phase 2 will start the preloader from here.
|
|
}
|
|
```
|
|
becomes:
|
|
```typescript
|
|
create(): void {
|
|
this.scene.start('Garden');
|
|
}
|
|
```
|
|
|
|
**EventBus singleton** — RESEARCH Pattern 3 lines 681-694:
|
|
```typescript
|
|
// src/game/event-bus.ts
|
|
import * as Phaser from 'phaser';
|
|
|
|
/** Single shared emitter — the Phaser 4 React-template pattern. */
|
|
export const eventBus = new Phaser.Events.EventEmitter();
|
|
```
|
|
|
|
**Garden scene shape** mirrors `Boot.ts:7-19` (constructor + preload + create + update). The scene's `update(_time, _delta)` calls `scheduler.advanceLive(clock.now())` once per frame — RESEARCH line 523.
|
|
|
|
**Boundary callout:** `src/game/` is game. ESLint default-allows; the scene MAY import `src/render/garden/` (renderers) and `src/store/index.ts` (read commands, write state via `simAdapter`). The Garden scene is the **only** place where sim + store + render meet — keep it thin (subscribe-and-dispatch).
|
|
|
|
---
|
|
|
|
### Group M — App.tsx + PhaserGame.tsx edits [Waves 1+2]
|
|
|
|
**Analog:** themselves — incremental edits to existing Phase-1 files.
|
|
|
|
**App.tsx pattern** — see Group I above.
|
|
|
|
**PhaserGame.tsx hook addition** (current `useEffect` at `src/PhaserGame.tsx:36-39` is a placeholder):
|
|
```typescript
|
|
useEffect(() => {
|
|
// Phase 2+: subscribe to scene-ready events here and surface the active scene
|
|
// through `currentActiveScene` so React can talk to Phaser.
|
|
}, []);
|
|
```
|
|
Phase 2 wires this up: subscribe to `eventBus.on('scene-ready', ...)`, surface the active scene to React via the `currentActiveScene` callback. RESEARCH Pattern 3 line 694.
|
|
|
|
**Save lifecycle hooks** — UX-10. Add `visibilitychange`, `beforeunload` listeners in a new `useEffect` that calls into `src/save/index.ts`:
|
|
```typescript
|
|
useEffect(() => {
|
|
const onHide = () => { /* serialize via src/save/wrap + db.put */ };
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') onHide();
|
|
});
|
|
window.addEventListener('beforeunload', onHide);
|
|
// …cleanup on unmount
|
|
}, []);
|
|
```
|
|
RESEARCH Pitfall 7 (line 1094): save fires AFTER React unmounts on `beforeunload`. Mitigation: keep the save-write code path synchronous inside the `beforeunload` handler — no `await`. Use `LocalStorageDBAdapter` synchronously here (already shipped at `src/save/db-localstorage-adapter.ts`); IndexedDB writes can lag.
|
|
|
|
**Boundary callout:** `src/PhaserGame.tsx` and `src/App.tsx` are typed as element `app` in `eslint.config.js:88`. They sit at the cross-tier boundary and may import all tiers — they're the binding code.
|
|
|
|
---
|
|
|
|
### Group N — Playwright e2e [Wave 2, Plan 02-05]
|
|
|
|
**Analog:** none — `playwright.config.ts` exists but no specs ship in Phase 1. Comment at `playwright.config.ts:5-6`: "First spec lands in Phase 2 (PIPE-07)."
|
|
|
|
**Config (already shipped)** (`playwright.config.ts:7-16`):
|
|
```typescript
|
|
export default defineConfig({
|
|
testDir: 'tests/e2e',
|
|
use: { baseURL: 'http://localhost:5173' },
|
|
webServer: {
|
|
command: 'npm run dev',
|
|
url: 'http://localhost:5173',
|
|
reuseExistingServer: true,
|
|
timeout: 30_000,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Spec skeleton** — RESEARCH lines 1377-1387 detail the URL-flag + FakeClock fast-forward. The spec at `tests/e2e/season1-loop.spec.ts` boots with `?devtime=fake`, advances `window.__tlgFakeClock.advance(...)` between assertions:
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('Season 1 full loop: Begin → Plant → Harvest → Journal → Reload → Persist', async ({ page }) => {
|
|
await page.goto('/?devtime=fake');
|
|
// ... assertions per the smoke flow
|
|
await page.evaluate(() => window.__tlgFakeClock.advance(5 * 60 * 1000));
|
|
// ... harvest, journal, reload, persist
|
|
});
|
|
```
|
|
|
|
**Production guard** — RESEARCH line 1387: "in `import.meta.env.PROD` builds, the URL flag is silently ignored". Implement in the boot path of `src/PhaserGame.tsx` or wherever the Clock is selected.
|
|
|
|
**Boundary callout:** `tests/e2e/` is outside `src/`. ESLint's `src/**/*` glob doesn't apply.
|
|
|
|
---
|
|
|
|
### Group O — ESLint config edit (recommended, optional) [Wave 0, Plan 02-01]
|
|
|
|
**Analog:** `eslint.config.js` itself — Phase 2 *extends* the firewall block.
|
|
|
|
**Recommended addition** (RESEARCH Pitfall 1, line 1037):
|
|
```javascript
|
|
{
|
|
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()',
|
|
}, {
|
|
selector: "CallExpression[callee.name='setInterval']",
|
|
message: 'src/sim/** must not use setInterval; the scheduler drives ticks via the Phaser game loop',
|
|
}],
|
|
},
|
|
},
|
|
```
|
|
|
|
**Existing analog** (`eslint.config.js:115-126`):
|
|
```javascript
|
|
rules: {
|
|
'boundaries/element-types': ['error', {
|
|
default: 'allow',
|
|
rules: [
|
|
{ from: ['sim'], disallow: ['render', 'ui'] },
|
|
],
|
|
}],
|
|
},
|
|
```
|
|
|
|
**Test analog** for the new rule — model on `src/sim/__test_violation__/lint-firewall.test.ts:19-49`:
|
|
```typescript
|
|
describe('CORE-10: src/sim/ cannot import from src/render/ or src/ui/', () => {
|
|
it('eslint-plugin-boundaries flags a sim → render import as an error', async () => {
|
|
const eslint = new ESLint({
|
|
overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'),
|
|
ignore: false,
|
|
});
|
|
const fixturePath = resolve(process.cwd(), 'src/sim/__test_violation__/violator.ts');
|
|
const results = await eslint.lintFiles([fixturePath]);
|
|
// ... assert ruleId === 'boundaries/element-types'
|
|
});
|
|
});
|
|
```
|
|
Phase 2 adds an analogous fixture (e.g., `src/sim/__test_violation__/date-now-violator.ts`) and an analogous test asserting `ruleId === 'no-restricted-syntax'` fires. Keep the fixture excluded via the existing `ignores` block at `eslint.config.js:43-49`.
|
|
|
|
**User pushback warning:** the user's CLAUDE.md auto-memory flags "feedback_avoid_overengineering — solo user prefers minimum-viable shape; no ceremonial workflows unless asked". The ESLint extension is a *defense* against Pitfall 1 (line 1029-1041), but the planner should expose it as a *defended option* in PLAN.md — not a locked task — and let the user decide whether to add it now or rely on code review.
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### Pattern: Per-layer public barrel
|
|
|
|
**Source:** `src/save/index.ts` (38 lines), `src/content/index.ts` (8 lines).
|
|
|
|
**Apply to:** every new layer — `src/sim/numbers/index.ts`, `src/sim/scheduler/index.ts`, `src/sim/garden/index.ts`, `src/sim/memory/index.ts`, `src/sim/narrative/index.ts`, `src/sim/offline/index.ts`, `src/sim/index.ts`, `src/store/index.ts`, `src/render/garden/index.ts`, `src/render/index.ts`, `src/ui/begin/index.ts`, `src/ui/journal/index.ts`, `src/ui/letter/index.ts`, `src/ui/dialogue/index.ts`, `src/ui/settings/index.ts`, `src/ui/garden/index.ts`, `src/ui/index.ts`.
|
|
|
|
**Concrete excerpt** (`src/save/index.ts:1-12`):
|
|
```typescript
|
|
/**
|
|
* Public surface of the save layer. Phase 2's tick scheduler + Zustand
|
|
* store are the first consumers — they should ONLY import from this
|
|
* file, never from the individual modules underneath. The internal
|
|
* shape is allowed to change between phases; this barrel is the
|
|
* stability contract.
|
|
*/
|
|
|
|
export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
|
|
export type { SaveEnvelope } from './envelope';
|
|
|
|
export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
|
|
```
|
|
|
|
**Rule:** consumers cross layer boundaries through `index.ts` only. Internal modules are private.
|
|
|
|
---
|
|
|
|
### Pattern: Vitest test colocation with `*.test.ts(x)` suffix
|
|
|
|
**Source:** `src/save/checksum.ts` ↔ `src/save/checksum.test.ts`; `src/content/loader.ts` ↔ `src/content/loader.test.ts`.
|
|
|
|
**Apply to:** every Phase-2 production module gets a colocated test file. Naming: `foo.ts` ↔ `foo.test.ts`; `Foo.tsx` ↔ `Foo.test.tsx`.
|
|
|
|
**Vitest config already includes both** (`vitest.config.ts:14`):
|
|
```typescript
|
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'scripts/**/*.test.mjs', 'scripts/**/*.test.ts'],
|
|
```
|
|
|
|
**Concrete imports** (`src/save/checksum.test.ts:1-2`):
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { crc32hex, canonicalJSON } from './checksum';
|
|
```
|
|
|
|
**Rule:** never put production code and test in the same file; never put tests under a separate `tests/unit/` tree (only Playwright `tests/e2e/`).
|
|
|
|
---
|
|
|
|
### Pattern: docblock-with-citation at the top of every module
|
|
|
|
**Source:** `src/save/checksum.ts:3-8`, `src/save/migrations.ts:1-13`, `src/save/envelope.ts:4-11`, every Phase-1 module.
|
|
|
|
**Apply to:** every new Phase-2 module gets a leading docblock that names:
|
|
1. The phase + plan that introduced it.
|
|
2. The CONTEXT decision(s) it implements (`D-XX`) AND/OR the RESEARCH pattern it implements (Pattern N).
|
|
3. The REQ-ID(s) it satisfies.
|
|
4. Any non-obvious constraint or pitfall it defends against.
|
|
|
|
**Concrete excerpt** (`src/save/migrations.ts:1-13`):
|
|
```typescript
|
|
/**
|
|
* Forward-only save migration registry.
|
|
*
|
|
* Each entry `migrations[N]` is the function that migrates payload from
|
|
* schema version N-1 to schema version N. Phase 1 ships migrations[1]
|
|
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
|
|
* migrations[2] when prestige / Roothold state lands.
|
|
*
|
|
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
|
|
* Phase 2's first feature commit will write. Authoring it now lets us
|
|
* prove the migration chain end-to-end without speculating about future
|
|
* Season 5+ structures.
|
|
*/
|
|
```
|
|
|
|
**Rule:** future readers (the verifier, the next phase's planner, the user-as-reviewer) read the docblock first. Make it count.
|
|
|
|
---
|
|
|
|
### Pattern: Zod schema + safeParse + bubble-up throw
|
|
|
|
**Source:** `src/content/loader.ts:31-40`, `src/save/codec.ts:51-75`, `src/content/schemas/fragment.ts:14-18`.
|
|
|
|
**Apply to:** every new Zod schema (Phase 2: `OfflineEventBlockSchema` in `src/sim/offline/events.ts`).
|
|
|
|
**Concrete excerpt** (`src/content/loader.ts:31-40`):
|
|
```typescript
|
|
function loadYamlFragments(): Fragment[] {
|
|
return Object.entries(yamlFiles).flatMap(([path, raw]) => {
|
|
const data = parseYAML(raw);
|
|
const parsed = SeasonContentSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
|
}
|
|
return parsed.data.fragments;
|
|
});
|
|
}
|
|
```
|
|
|
|
**Rule:** always `safeParse` (never `parse`); always include the source path / context in the throw; let the throw bubble up — don't swallow.
|
|
|
|
---
|
|
|
|
### Pattern: ESLint boundary classification
|
|
|
|
**Source:** `eslint.config.js:80-90`.
|
|
|
|
**Apply to:** every new directory created under `src/`. The classification table at `eslint.config.js:80-90` lists nine element types:
|
|
```javascript
|
|
'boundaries/elements': [
|
|
{ type: 'sim', pattern: 'src/sim/**' },
|
|
{ type: 'render', pattern: 'src/render/**' },
|
|
{ type: 'ui', pattern: 'src/ui/**' },
|
|
{ type: 'save', pattern: 'src/save/**' },
|
|
{ type: 'content', pattern: 'src/content/**' },
|
|
{ type: 'audio', pattern: 'src/audio/**' },
|
|
{ type: 'store', pattern: 'src/store/**' },
|
|
{ type: 'app', pattern: 'src/{main,App,PhaserGame}.{ts,tsx}' },
|
|
{ type: 'game', pattern: 'src/game/**' },
|
|
],
|
|
```
|
|
|
|
**Rule:** Phase 2 introduces no NEW element types. Every new file falls under an existing pattern. If the planner finds a file that doesn't fit, **stop and ask** — don't add a new element type without explicit approval.
|
|
|
|
**Critical firewall rule** (`eslint.config.js:120-125`):
|
|
```javascript
|
|
'boundaries/element-types': ['error', {
|
|
default: 'allow',
|
|
rules: [
|
|
{ from: ['sim'], disallow: ['render', 'ui'] },
|
|
],
|
|
}],
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern: Phaser ↔ React communication via Zustand store + EventBus singleton
|
|
|
|
**Source:** RESEARCH Pattern 3 (lines 612-696). No Phase-1 analog ships yet, but `src/PhaserGame.tsx:36-39` is shaped for it.
|
|
|
|
**Apply to:** every render-tier ↔ ui-tier signal in Phase 2.
|
|
|
|
**Two channels:**
|
|
1. **Persistent state → Zustand store** (`src/store/`). Tile contents, plant growth, harvested fragments, beat progress, settings, queued commands.
|
|
2. **Transient signals → Phaser EventBus** (`src/game/event-bus.ts`). `scene-ready`, `tile-clicked-coords` (carries seed-picker mount position), `fragment-revealed` (one-shot to fire the reveal modal).
|
|
|
|
**Concrete excerpt** (RESEARCH lines 681-694):
|
|
```typescript
|
|
// src/game/event-bus.ts
|
|
import * as Phaser from 'phaser';
|
|
export const eventBus = new Phaser.Events.EventEmitter();
|
|
|
|
// Sample events Phase 2 will emit/listen for:
|
|
// 'scene-ready' (Phaser → React) signals scene tree is live
|
|
// 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker
|
|
// 'request-active-scene' (React → Phaser) one-shot
|
|
// 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal
|
|
```
|
|
|
|
**Anti-pattern:** routing user-input intents through the EventBus (RESEARCH line 696). User intents = commands → store. Transient signals → EventBus.
|
|
|
|
---
|
|
|
|
### Pattern: Pure-function sim with injected time
|
|
|
|
**Source:** RESEARCH Pattern 1 (lines 434-540) + CLAUDE.md Code Style.
|
|
|
|
**Apply to:** every function in `src/sim/garden/`, `src/sim/memory/`, `src/sim/narrative/`, `src/sim/offline/`.
|
|
|
|
**Rule:** sim functions take time as an argument: `simulate(state, dtTicks, commands, { now })`. They never call `Date.now()`, `performance.now()`, `setInterval`, `setTimeout` (with a non-zero arg), or `requestAnimationFrame`. Timers are the scheduler's responsibility.
|
|
|
|
**Anti-pattern excerpt** (`src/save/migrations.ts:55`):
|
|
```typescript
|
|
lastTickAt: Date.now(),
|
|
```
|
|
This `Date.now()` call inside `migrations[1]` is intentional — it's the boundary at save time, not sim time. The migration runs once at boot, in the application layer, not inside the sim loop. **Sim modules under `src/sim/` will not have this pattern.**
|
|
|
|
---
|
|
|
|
### Pattern: Save lifecycle hooks via React useEffect + browser events
|
|
|
|
**Source:** RESEARCH Pitfall 7 (line 1094) + CONTEXT D-32 wiring.
|
|
|
|
**Apply to:** `src/PhaserGame.tsx` (only).
|
|
|
|
**Rule:** save-on-hide and save-on-unload listeners attach in `useEffect` inside `PhaserGame.tsx`. The `beforeunload` handler MUST be synchronous (no `await`) because React unmounts asynchronously. Use the synchronous `LocalStorageDBAdapter` write path; the `idb`-backed write may lose the last few hundred ms of state on `beforeunload`, which is acceptable per the multi-layer save model.
|
|
|
|
---
|
|
|
|
## No Analog Found
|
|
|
|
Files with no Phase-1 analog. Planner uses RESEARCH.md patterns directly:
|
|
|
|
| File | Role | Reason | RESEARCH reference |
|
|
|------|------|--------|--------------------|
|
|
| `src/sim/scheduler/clock.ts`, `tick.ts`, `catchup.ts` | sim/scheduler | First wall-clock owner in the project | Pattern 1 (lines 434-540) |
|
|
| `src/store/store.ts` + slice files | store | First Zustand store; first ESLint `store` element population | Pattern 3 (lines 612-676) |
|
|
| `src/render/garden/*.ts` | render/phaser | First population of `src/render/` (only `__firewall_target__.ts` exists today) | Pattern 4 (lines 698-740) for tile coords |
|
|
| `src/ui/garden/SeedPicker.tsx` | ui/popover | First DOM popover over Phaser canvas | Pattern 4 (lines 698-740) |
|
|
| `src/ui/begin/use-audio-bootstrap.ts` | ui/audio | First AudioContext bootstrap | Pattern 9 (lines 942-992) |
|
|
| `src/ui/dialogue/ink-runtime.ts`, `src/ui/letter/letter-renderer.ts` | ui/ink | First inkjs runtime integration | Pattern 5 (lines 741-801) + Pattern 6 (lines 802-840) |
|
|
| `src/game/event-bus.ts` | game/singleton | First Phaser.Events.EventEmitter singleton | Pattern 3 (lines 681-694) |
|
|
| `tests/e2e/season1-loop.spec.ts` | test/e2e | First Playwright spec | RESEARCH Sim-Clock Injection (lines 1377-1387) |
|
|
| `scripts/compile-ink.mjs` | build/script | First inklecate batch | RESEARCH Pattern 5 (lines 743-800), Assumption A6 |
|
|
|
|
**Common thread:** these all live at integration boundaries that Phase 1 deliberately deferred to Phase 2. The RESEARCH patterns are the substitute for an existing analog — copy the skeleton verbatim.
|
|
|
|
---
|
|
|
|
## Phase 2 Boundary Callout Cheat-Sheet
|
|
|
|
For the planner: every PLAN.md task that creates a new file MUST include a one-line statement of which ESLint element it falls under and what it can/cannot import. Use this table:
|
|
|
|
| New file lives in | ESLint element | Can import | CANNOT import |
|
|
|-------------------|----------------|------------|---------------|
|
|
| `src/sim/**` | `sim` | `src/sim/**` (within), 3rd-party libs only | `src/render/**`, `src/ui/**` (firewall, error severity) |
|
|
| `src/store/**` | `store` | anywhere (default-allow) | n/a |
|
|
| `src/render/**` | `render` | `src/store/**` via barrel, 3rd-party (Phaser) | sim modules directly (smell, not lint-enforced) |
|
|
| `src/ui/**` | `ui` | `src/store/**`, `src/save/**`, `src/content/**` via barrels, 3rd-party (React, inkjs) | `src/render/**`, `src/game/**`, `src/sim/**` directly |
|
|
| `src/game/**` | `game` | anywhere (default-allow); especially `src/render/**` and `src/store/**` | smell to import `src/sim/**` directly — go through scheduler + store |
|
|
| `src/content/**` | `content` | 3rd-party (zod, gray-matter, yaml, inkjs) | n/a |
|
|
| `src/save/**` | `save` | 3rd-party (idb, lz-string, crc-32, zod) | sim/render/ui (save is a leaf utility) |
|
|
| `src/{main,App,PhaserGame}.tsx` | `app` | anywhere | n/a (this is the binding layer) |
|
|
| `tests/e2e/**` | (outside `src/`) | anywhere | n/a |
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:** `src/**/*.{ts,tsx}` (32 files), `eslint.config.js`, `vitest.config.ts`, `playwright.config.ts`, `package.json`, `content/**`, `scripts/**`.
|
|
**Files scanned (read in full):** `src/save/index.ts`, `src/save/migrations.ts`, `src/save/envelope.ts`, `src/save/persist.ts`, `src/save/snapshots.ts`, `src/save/codec.ts`, `src/save/checksum.ts`, `src/save/checksum.test.ts`, `src/save/migrations.test.ts`, `src/content/index.ts`, `src/content/loader.ts`, `src/content/loader.test.ts`, `src/content/schemas/fragment.ts`, `src/content/schemas/index.ts`, `src/content/schemas/season.ts`, `src/game/main.ts`, `src/game/scenes/Boot.ts`, `src/App.tsx`, `src/PhaserGame.tsx`, `src/main.tsx`, `src/sim/__test_violation__/lint-firewall.test.ts`, `src/sim/__test_violation__/violator.ts`, `src/render/__firewall_target__.ts`, `eslint.config.js`, `vitest.config.ts`, `playwright.config.ts`, `package.json`, `content/README.md`, `content/seasons/00-demo/fragments.yaml`. RESEARCH.md and CONTEXT.md read as required-reading inputs (RESEARCH read in three targeted sections: Pattern map header, Pattern 7 (save extension), Phase Requirements → Test Map).
|
|
**Files analyzed for classification:** 54 (49 NEW + 5 MODIFIED).
|
|
**Pattern extraction date:** 2026-05-09.
|
|
**Doctrine carried forward:**
|
|
- Architectural firewall is non-negotiable (CORE-10, ESLint-enforced).
|
|
- BigQty wrapper from day one of feature code (CLAUDE.md Code Style).
|
|
- Sim is pure; clock injects time (CLAUDE.md + RESEARCH Pattern 1).
|
|
- Save extension, not migration (CONTEXT D-34, RESEARCH Pattern 7).
|
|
- Single public barrel per layer (`index.ts`).
|
|
- Stable string fragment IDs (regex enforced by `FragmentSchema`).
|
|
- Zod safeParse + bubble-up throw on schema violations.
|
|
- User flagged "minimum-viable shape; no ceremonial workflows" — apply to every plan task; defended options surfaced, not auto-locked.
|