# Phase 1: Foundations & Doctrine — Research **Researched:** 2026-05-08 **Domain:** Project scaffolding, browser save framework, content build pipeline, AI-asset provenance gate, architectural-boundary linting, principle-level doctrine documents **Confidence:** HIGH (all stack versions verified against npm registry; all integration patterns verified against official docs) ## Summary Phase 1's research target was narrow by design — the user has already locked the stack (Phaser 4, React 19, Vite, TypeScript, idb, lz-string, Zod, Vitest, Playwright) and rejected every overengineered shape (no curator workflow, no two-stage AI promotion, no lint rules on UX strings, no pre-allocated save slots, no BigQty/Zustand/tick scheduler in this phase). Research therefore concentrated on **the cheapest correct shape** for each of the five Phase-1 success criteria: (1) the official `npm create @phaserjs/game@latest` React+Vite+TS template structure and how to restructure `src/` into the firewall directories, (2) the ESLint boundary rule (`eslint-plugin-boundaries` v6.0.2 wins on cleanness), (3) a minimum-viable `idb` save layer with versioned `{schemaVersion, payload, checksum}` envelope, lz-string compression, last-3 snapshots, and Base64 export/import, (4) a Vite-native content pipeline using `import.meta.glob('/content/**/*', { eager, query: '?raw' })` + gray-matter + Zod (no separate build script needed), and (5) a 30-line standalone Node script that walks `assets/` and validates sidecar `.provenance.json` files. All recommended package versions were verified against npm on 2026-05-08: Phaser 4.1.0, React 19.2.6, Vite 8.0.11, TypeScript 6.0.3, idb 8.0.3, lz-string 1.5.0, Zod 4.4.3, Vitest 4.1.5, Playwright 1.59.1, eslint-plugin-boundaries 6.0.2, crc-32 1.2.2, gray-matter 4.0.3, yaml 2.8.4, inkjs 2.4.0, inklecate 1.8.1. **Primary recommendation:** Lean on the Phaser 4 official template heavily — do not restructure aggressively. Add the firewall directories (`src/sim/`, `src/render/`, `src/ui/`, `src/save/`, `src/content/`, `src/audio/`, `src/store/`) as siblings to the template's existing `src/game/` directory; the template's existing entry points (`src/main.tsx`, `src/App.tsx`, `src/PhaserGame.tsx`) keep working. This is the minimum-viable scaffold that exposes ESLint boundary targets without fighting the template. ## User Constraints (from CONTEXT.md) ### Locked Decisions **AI Asset Pipeline Depth (D-01, D-02, D-03):** - End-of-Phase-1 north-star reference set is **10–20 hand-curated AI generations** committed to the repo with full provenance metadata. Generated honestly with whatever tool was used (likely Claude/SD/Midjourney/etc. on a per-asset basis); the `model_id` and `checkpoint_hash` fields record what was actually used. - **Tool/vendor choice deferred** — Phase 1 commits to the *schema* and *gate*, not a vendor. Tool consolidation happens in Phase 5 when production volume kicks in. - **Curation gate is minimum-viable.** Sidecar `name.provenance.json` per asset under `/assets/` carrying the 6 required fields (`model_id`, `checkpoint_hash`, `prompt`, `seed`, `sampler`, `params`); CI script walks the tree and fails the build on any missing/invalid sidecar. Sample refused asset under `/assets/__samples__/refused/` (no sidecar) proves the gate works. **No** curator workflow, **no** two-stage promotion directory, **no** pre-commit hook, **no** CURATION-LOG.md ceremony. **Save Schema v1 Scope (D-04, D-05, D-06):** - v1 save payload is **minimal** — only what Phase 2 will write (garden tile state, plant growth data, harvested fragment IDs, `lastTickAt`, basic settings). It does NOT pre-allocate slots for Roothold, currentSeason, storyFlags, etc. Phase 4 ships a real `migrate_v1_to_v2` when Season-prestige state actually exists. - Round-trip migration test in Phase 1 uses a **synthetic v0 → v1 migration** as the proof that the chain works. v0 is a tiny made-up prior schema (e.g., `{garden: []}`) used only to exercise the migration framework end-to-end. - Save format `{schemaVersion, payload, checksum}` is locked. Checksum algorithm and exact migration registry shape are Claude's discretion. **Doctrine Docs Concreteness (D-07, D-08, D-09):** - Anti-FOMO doctrine is a **consolidation document** in `.planning/`. **No** lint rule on UX strings. - Season 7 end-state design is **principle-level**, not treatment-level. - Both doctrine documents live under `.planning/`. **Project Scaffold Layout (D-10, D-11, D-12):** - Use `npm create @phaserjs/game@latest` (React + Vite + TypeScript template). Restructure `src/` to expose `src/sim/`, `src/render/`, `src/ui/`, plus supporting `src/save/`, `src/content/`, `src/audio/`, `src/store/` as siblings. - Authored content (`/content/**/*.{md,yaml,ink}`) lives at **repo root**. - `/assets/` (with provenance sidecars) lives at repo root. Single package, **no monorepo / no workspaces**. ### Claude's Discretion - Exact ESLint boundary rule choice (`eslint-plugin-boundaries` vs `no-restricted-paths`). - Checksum algorithm (CRC32, simple hash, etc.). - Migration registry shape. - `idb` wrapper API surface inside `src/save/`. - Vitest / Playwright config files; `vite.config.ts` boundary plugin choices. - Where the Zod content schemas live and how the build step is invoked (Vite plugin vs separate `npm run build:content` script). - Specific images for the 10–20 north-star generations. ### Deferred Ideas (OUT OF SCOPE) - AI vendor lock-in / model pinning (Phase 5). - `BigQty` wrapper around break_eternity.js (Phase 2). - Empty Zustand store skeleton (Phase 2). - Tick scheduler / monotonic clock (Phase 2). - Season 7 treatment-level details (Phase 7). - Anti-FOMO lint rule on UX strings (rejected). - Curator workflow / two-stage asset promotion / pre-commit hook on assets (rejected). - Visual regression testing (Phase 8). - AudioContext / Howler.js setup (Phase 2). - Phaser scene wiring or rendering (Phase 2-3). - Ink dialogue authoring (Phase 2+). ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | CORE-01 | Game loads in <5s in modern browsers (Chrome/Firefox/Safari/Edge last 2) on 25 Mbps | Phaser 4 template ships Vite 8 with sensible default code-splitting; the empty scaffold loads in well under 5s. The **enforcement** mechanism is a Playwright smoke test introduced in Phase 2 (PIPE-07); Phase 1 verifies by `npm run build` succeeding and visual sanity in dev. | | CORE-04 | Save persists to IndexedDB with localStorage fallback | `idb` 8.0.3 wraps IndexedDB in promises; localStorage fallback is a try/catch around `openDB` failures. See "Save Layer Shape" below. | | CORE-05 | `navigator.storage.persist()` called on first save with respectful surfacing of false | Available in Chrome/Firefox/Edge (Safari 17+ partial); when false, surface "saves may be cleared if storage runs low — export a backup from Settings" at next opportunity. | | CORE-06 | Saves versioned `{schemaVersion, payload, checksum}`; refuse load on checksum mismatch with recovery option | crc-32 (`crc-32` package, 426k ops/sec, dependency-free, ~3KB) is the right shape. See "Checksum Choice" below. | | CORE-07 | `migrate_vN_to_vN+1` chain with Vitest tests for every shipped migration | Ordered registry pattern; synthetic v0→v1 demo migration exercises the chain end-to-end. See "Migration Registry Shape" below. | | CORE-08 | Last 3 pre-migration save snapshots retained; "restore previous save" reachable from settings (UI hooks land in Phase 4) | Separate `snapshots` object store keyed by `{schemaVersion, savedAt}`; pre-migration hook writes to it; pruner runs on each write. Phase 1 ships only the storage + retention; settings UI is Phase 4. | | CORE-09 | Save export/import as Base64 text via Settings (function pair shipped Phase 1; UI button Phase 2+) | `lz-string.compressToBase64` of the JSON envelope is the export; `decompressFromBase64` is the import. Round-trip test in Vitest. | | CORE-10 | `src/sim/` imports nothing from `src/render/` or `src/ui/` — enforced by ESLint boundary rules in CI | `eslint-plugin-boundaries` 6.0.2 with the `boundaries/element-types` rule. See "ESLint Boundary Rule" below. | | PIPE-01 | Build compiles `/content/**/*.{md,yaml,ink}` into per-Season JSON via Zod-validated schemas; build fails on schema violation | Vite-native `import.meta.glob('/content/**/*', { eager: true, query: '?raw' })` + gray-matter + `yaml` + Zod, validated at build time. See "Content Pipeline" below. | | PIPE-03 | AI asset pipeline records provenance per asset and refuses unprovenanced assets | Standalone `scripts/validate-assets.mjs` walks `assets/` (excluding `__samples__/refused/`); fails CI on any non-sidecar file lacking a sibling `.provenance.json` validated by a Zod schema. | | PIPE-05 | `anti-FOMO doctrine` and `Season 7 end-state` design documents in `.planning/` before economy code | Outlined below in "Doctrine Doc Outlines". Principle-level, not treatment. | | PIPE-06 | Vitest unit tests cover all save migrations and run on every CI build | Vitest 4.1.5 with a `pnpm/npm test` step in CI. Test files: `src/save/migrations.test.ts`, `src/save/round-trip.test.ts`. | | AEST-08 | All AI-assisted assets carry persisted provenance metadata (`{model_id, checkpoint_hash, prompt, seed, sampler, params}`) | Sidecar `.provenance.json` schema enforced by validator script. The "pinned model" wording in REQUIREMENTS is satisfied by per-asset honest provenance for Phase 1; vendor pinning is Phase 5. | | AEST-09 | All shipped assets pass a mandatory human curation gate before integration | The validator script *is* the gate (CI fails if sidecar missing/invalid). Phase 1 also commits the 10–20-image north-star reference set as the seed. **No** curator workflow doc, per CONTEXT D-03. | | STRY-09 | Every player-visible string externalized in `/content/`, not hardcoded | Phase 1 establishes the `/content/` convention and the loader; **no lint rule** enforces this (per CONTEXT D-07). Phase 2 begins authoring real strings into `/content/season-01-soil/`. | | UX-13 | No daily login bonuses, streaks, limited-time content, nag notifications, loss-aversion copy — anti-FOMO doctrine enforced in every UX review | Doctrine doc shipped in this phase; enforcement is by review, not code. | ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Project scaffold (vite, tsconfig, eslint baseline) | Build / Dev tooling | — | Ships the dev-server + bundler boundary — not user-facing. | | Save persistence (IndexedDB, lz-string, checksum) | Browser / Client (Storage) | — | All save state is local-first per CLAUDE.md "no always-online" constraint. | | Save migration chain | Browser / Client (Storage) | Simulation core consumer | Migration runs at load time before sim sees state; pure functions, no I/O. | | Save export/import (Base64) | Browser / Client (Storage) | UI (Phase 2+ wires button) | Function pair lives in `src/save/`; UI surface is later. | | Content build pipeline (md/yaml/ink → JSON) | Build / Static asset transform | Browser runtime consumer | Vite plugin pattern at build; runtime imports compiled JSON only. | | Zod content schemas | Build / Static asset validation | Browser runtime type source | Schemas validate at build, derive TS types via `z.infer` at compile. | | AI asset provenance gate | CI / Build-time validator | Repo discipline | Standalone Node script; no runtime import. Lives in `scripts/`. | | ESLint boundary rule | Build / Static-analysis | — | Lints `src/sim/` ↔ `src/render/`/`src/ui/` import edges; runs in CI and IDE. | | Anti-FOMO doctrine | Process / Documentation | UX review reference | Markdown in `.planning/`; not enforced by code. | | Season 7 end-state doctrine | Process / Documentation | Future Phase 7 reference | Markdown in `.planning/`; principle-level. | | North-star asset reference set | Repo / Static asset | Future Phase 5 model-pinning input | 10–20 images committed under `assets/north-stars/` with sidecars. | **Important:** Every Phase 1 capability is **build/CI/storage tier** — there is no Simulation or Rendering responsibility in this phase. Phase 2 is where Simulation Core takes ownership. ## Standard Stack ### Core (versions verified against npm 2026-05-08) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | `phaser` | 4.1.0 | 2D game framework (canvas owner, Phase 2+ uses it) | Locked by CONTEXT D-10. Verified `npm view phaser version` → 4.1.0 ("Salusa", April 2026). | | `react` / `react-dom` | 19.2.6 | UI shell (DOM overlay) | Locked by CLAUDE.md / STACK.md. `npm view react version` → 19.2.6. | | `vite` | 8.0.11 | Dev server + bundler | Phaser 4 official template ships with Vite. `npm view vite version` → 8.0.11. **NB: STACK.md mentions Vite 6 as baseline; the template version may be 6 or 7 — accept whatever the scaffold installs and don't fight it.** Vite 8 with Rolldown is current latest. | | `typescript` | 6.0.3 | Static typing | `npm view typescript version` → 6.0.3. | | `idb` | 8.0.3 | Promise-based IndexedDB wrapper | Locked. `npm view idb version` → 8.0.3. ~1.19kB brotli'd; openDB + put/get covers Phase 1 entirely. | | `lz-string` | 1.5.0 | Save compression + Base64 export | Locked. `compressToBase64` / `decompressFromBase64` is the export round-trip. | | `zod` | 4.4.3 | Schema validation (content + save envelope + provenance) | Locked. **Note:** STACK.md cites 3.23+; current is 4.4.3 — Zod 4 is the modern major. Use 4.x for new code. | | `vitest` | 4.1.5 | Test runner (migration tests + round-trip test) | Locked. Vite-native; covers PIPE-06. | | `@playwright/test` | 1.59.1 | E2E smoke (no test in Phase 1; install only — first test in Phase 2) | Locked by STACK.md. Phase 1 only `npm install`s it; first real spec is PIPE-07 in Phase 2. | | `eslint-plugin-boundaries` | 6.0.2 | Architectural-firewall lint rule | Cleaner than `no-restricted-paths` for the `src/sim/` ↔ `src/render/`/`src/ui/` rule. See decision rationale below. | | `crc-32` | 1.2.2 | Save checksum | Dependency-free; `CRC32.str(jsonString)` returns a 32-bit signed int; ~3KB. Fastest pure-JS CRC implementation in benchmarks (~426k ops/sec). | | `gray-matter` | 4.0.3 | Parse YAML frontmatter from `.md` files | Standard for the fragment manifest; STACK.md already endorses. | | `yaml` | 2.8.4 | Parse pure `.yaml` content files | `eemeli/yaml`, dependency-free, full YAML 1.2 spec; replaces `js-yaml` for new code. | ### Supporting (deferred — install but do not use yet) | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | `inkjs` | 2.4.0 | Ink narrative runtime | **Install in Phase 1** so the dependency is locked. **Do NOT compile any `.ink` files in Phase 1.** Phase 2 begins authoring + compilation. | | `inklecate` | 1.8.1 | Ink compiler CLI | Install as devDependency. Phase 1 includes a `package.json` script `compile:ink` that *exists* (so the build pipeline shape is proven) but is invoked on an empty `/content/dialogue/` directory and is a no-op. Phase 2 starts adding `.ink` files. | **Phase 1 explicitly does NOT install:** `zustand`, `break_eternity.js`, `howler` + `@types/howler`, `mitt`, `dayjs` — those land in Phase 2 with the simulation core. Do not pre-install — keeping `package.json` honest about phase scope is itself part of the discipline. ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | `eslint-plugin-boundaries` | `eslint-plugin-import` rule `no-restricted-paths` | `no-restricted-paths` requires manually enumerating every forbidden path pair, no glob support, no element-type abstraction. For one rule (sim ↔ render/ui) it would work in ~10 lines but the model is awkward. `eslint-plugin-boundaries` defines element types once and rules read clearly. **Pick boundaries.** | | `crc-32` | `xxhashjs` / `murmurhash` / `Web Crypto SHA-256 (sliced)` | xxhash is faster on large strings but heavier dependency; murmurhash is comparable but slightly less battle-tested in JS. SHA-256 is overkill for non-adversarial corruption detection (a save isn't a security boundary — checksum mismatch just signals "don't load this"). **CRC-32 is correct: deterministic, ~3KB, dependency-free, fast enough.** Idle-game saves are bytes-to-low-megabytes; even a slower hash would be fine. | | Vite-native content pipeline (`import.meta.glob`) | Separate `npm run build:content` Node script | The Vite-native path is one less moving piece — content imports become typed values at build time, HMR works for free, Vite's tree-shaking handles per-Season chunking via dynamic glob in Phase 2. The standalone-script path is more flexible (can emit JSON to disk that Vite never sees) but Phase 1 has no need. **Vite-native wins on simplicity.** Keep the option to refactor later if Phase 5+ asset volume forces it. | | Synthetic v0→v1 migration in Phase 1 | Skip migration test until Phase 4's real v1→v2 | CONTEXT D-05 explicitly mandates the synthetic test. The point is to prove the framework, not the data shape. **Synthetic v0 is correct.** | | `crc-32` with `CRC32.str()` over JSON string | Hash the parsed object | Hashing the canonical JSON string is deterministic across runs only if `JSON.stringify` is canonical (key order). Since save state is internally controlled (we write the schema), key order is stable. Document the canonicalization in `src/save/checksum.ts`. | ### Installation ```bash # Step 0: scaffold (run interactively, choose "React + Vite + TypeScript") npm create @phaserjs/game@latest the-last-garden cd the-last-garden # Step 1: Phase 1 production deps npm install idb@^8 lz-string@^1.5 zod@^4 crc-32@^1.2 gray-matter@^4 yaml@^2.8 inkjs@^2.4 # Step 2: Phase 1 dev deps npm install -D vitest@^4 @playwright/test@^1.59 eslint-plugin-boundaries@^6 inklecate@^1.8 ``` The Phaser 4 template already installs `phaser`, `react`, `react-dom`, `vite`, `typescript`, and an ESLint baseline. Add to it; don't replace it. ### Version verification log | Package | Verified version | Date | |---------|-----------------|------| | phaser | 4.1.0 | 2026-05-08 | | react | 19.2.6 | 2026-05-08 | | vite | 8.0.11 | 2026-05-08 | | typescript | 6.0.3 | 2026-05-08 | | idb | 8.0.3 | 2026-05-08 | | lz-string | 1.5.0 | 2026-05-08 | | zod | 4.4.3 | 2026-05-08 | | vitest | 4.1.5 | 2026-05-08 | | @playwright/test | 1.59.1 | 2026-05-08 | | eslint-plugin-boundaries | 6.0.2 | 2026-05-08 | | crc-32 | 1.2.2 | 2026-05-08 | | gray-matter | 4.0.3 | 2026-05-08 | | yaml | 2.8.4 | 2026-05-08 | | inkjs | 2.4.0 | 2026-05-08 | | inklecate | 1.8.1 | 2026-05-08 | | howler | 2.2.4 | 2026-05-08 (Phase 2) | | break_eternity.js | 2.1.3 | 2026-05-08 (Phase 2) | | zustand | 5.0.13 | 2026-05-08 (Phase 2) | [VERIFIED: npm registry, 2026-05-08] ## Architecture Patterns ### System Architecture Diagram (Phase 1 deliverables only) ``` ┌──────────────────────────────────────────────────────────────────────┐ │ REPO ROOT │ │ /content/ → authored md+yaml+ink (Phase 2+ fills it) │ │ /assets/ → AI generations + sidecar provenance JSON │ │ /.planning/ → doctrine docs (anti-fomo, season-7-end-state) │ │ /scripts/ → validate-assets.mjs (CI provenance gate) │ │ │ │ /src/ │ │ ├── main.tsx, App.tsx, PhaserGame.tsx (template-provided) │ │ ├── game/ (template-provided — Phaser scenes) │ │ ├── sim/ [FIREWALL: imports nothing from render/ or ui/] │ │ ├── render/ (Phase 2+ Phaser scene wiring) │ │ ├── ui/ (Phase 2+ React HUD) │ │ ├── save/ ← LOAD-BEARING IN PHASE 1 │ │ │ ├── db.ts (idb wrapper: openDB + 2 stores) │ │ │ ├── envelope.ts ({schemaVersion, payload, checksum}) │ │ │ ├── checksum.ts (CRC32 over canonical JSON) │ │ │ ├── migrations.ts (registry: {1: v0→v1, ...}) │ │ │ ├── snapshots.ts (last-3 pre-migration retention) │ │ │ ├── persist.ts (navigator.storage.persist + result handler) │ │ │ ├── codec.ts (lz-string + Base64 export/import) │ │ │ └── *.test.ts (vitest: round-trip + each migration) │ │ ├── content/ ← LOAD-BEARING IN PHASE 1 │ │ │ ├── schemas/ (zod schemas: fragment.ts, season.ts) │ │ │ └── loader.ts (import.meta.glob + gray-matter + zod parse) │ │ ├── audio/ (empty placeholder for Phase 2) │ │ └── store/ (empty placeholder for Phase 2) │ │ │ │ /eslint.config.js (extends template + boundaries plugin) │ │ /vitest.config.ts │ │ /playwright.config.ts (installed; no specs yet) │ │ /package.json │ └──────────────────────────────────────────────────────────────────────┘ Build pipeline (Phase 1) CI gate (Phase 1) ┌─────────────────────────┐ ┌──────────────────────────┐ │ vite build │ │ npm run lint │ │ → import.meta.glob │ │ (eslint-plugin-bounds) │ │ → zod-parse content │ │ npm test │ │ → fail on violation │ │ (vitest: migrations) │ └─────────────────────────┘ │ node scripts/ │ │ validate-assets.mjs │ │ (provenance walker) │ └──────────────────────────┘ ``` ### Recommended Project Structure ``` the-last-garden/ ├── .planning/ │ ├── PROJECT.md (existing) │ ├── REQUIREMENTS.md (existing) │ ├── ROADMAP.md (existing) │ ├── STATE.md (existing) │ ├── anti-fomo-doctrine.md (NEW — Phase 1 deliverable) │ ├── season-7-end-state.md (NEW — Phase 1 deliverable) │ ├── research/ (existing) │ └── phases/ (existing) ├── content/ (NEW empty tree — Phase 2+ fills) │ ├── seasons/ (per-Season subdirs added in Phase 2+) │ └── README.md (explains the content shape) ├── assets/ │ ├── north-stars/ (NEW — 10–20 curated images + sidecars) │ │ ├── garden-soil-01.png │ │ ├── garden-soil-01.provenance.json │ │ └── ... │ └── __samples__/ │ └── refused/ │ └── unprovenanced.png (proves the gate) ├── scripts/ │ └── validate-assets.mjs (NEW — CI provenance walker) ├── src/ │ ├── main.tsx (template-provided) │ ├── App.tsx (template-provided) │ ├── PhaserGame.tsx (template-provided) │ ├── game/ (template-provided — Phaser scenes) │ ├── sim/ (NEW empty — Phase 2+ fills) │ │ └── .gitkeep │ ├── render/ (NEW empty — Phase 2+ fills) │ │ └── .gitkeep │ ├── ui/ (NEW empty — Phase 2+ fills) │ │ └── .gitkeep │ ├── save/ (NEW — Phase 1 ships) │ │ ├── db.ts │ │ ├── envelope.ts │ │ ├── checksum.ts │ │ ├── migrations.ts │ │ ├── snapshots.ts │ │ ├── persist.ts │ │ ├── codec.ts │ │ ├── round-trip.test.ts │ │ └── migrations.test.ts │ ├── content/ (NEW — Phase 1 ships loader, no real content yet) │ │ ├── schemas/ │ │ │ ├── fragment.ts │ │ │ └── season.ts │ │ └── loader.ts │ ├── audio/ (NEW empty — Phase 2+) │ │ └── .gitkeep │ └── store/ (NEW empty — Phase 2+) │ └── .gitkeep ├── eslint.config.js (extends template, adds boundaries plugin) ├── vitest.config.ts (NEW) ├── playwright.config.ts (NEW — empty test dir, install only) ├── tsconfig.json (template-provided, may add path alias) ├── vite.config.ts (template-provided) ├── package.json └── CLAUDE.md (existing) ``` ### Pattern 1: Save Envelope `{schemaVersion, payload, checksum}` **What:** Every save written to disk is a top-level envelope: ```typescript interface SaveEnvelope { schemaVersion: number; payload: T; checksum: string; // hex CRC32 of canonical JSON.stringify(payload) } ``` The checksum is computed over the canonical JSON string of the *payload only* (not the envelope). On load, recompute and compare; mismatch → refuse to load, surface recovery option. **When to use:** Always. CLAUDE.md mandates the shape; Phase 2+ writes payloads of this shape exclusively. **Example:** ```typescript // src/save/envelope.ts import { z } from 'zod'; import { crc32hex } from './checksum'; export const SaveEnvelopeSchema = z.object({ schemaVersion: z.number().int().positive(), payload: z.unknown(), checksum: z.string().regex(/^[0-9a-f]{8}$/), }); export type SaveEnvelope = z.infer & { payload: T }; export function wrap(payload: T, schemaVersion: number): SaveEnvelope { return { schemaVersion, payload, checksum: crc32hex(canonicalJSON(payload)), }; } export function unwrap(env: SaveEnvelope): T { const expected = crc32hex(canonicalJSON(env.payload)); if (expected !== env.checksum) throw new SaveCorruptError(env.checksum, expected); return env.payload as T; } // canonicalJSON: stable key order. For Phase 1, JSON.stringify with sorted keys is enough. function canonicalJSON(value: unknown): string { return JSON.stringify(value, (_k, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v ); } ``` [CITED: zod docs https://zod.dev — schema-from-type pattern] ### Pattern 2: Migration Registry (ordered chain) **What:** A `Record` mapping target-version → pure function. Loader walks from current envelope's version to `CURRENT_SCHEMA_VERSION`, applying each migration in turn. ```typescript // src/save/migrations.ts type Migration = (payload: unknown) => unknown; export const CURRENT_SCHEMA_VERSION = 1; // Each entry migrates FROM (key-1) TO key. So migrations[1] = v0→v1. export const migrations: Record = { 1: (s: any) => ({ // Synthetic demo: v0 was {garden: []}. v1 is the real Phase 2 shape. garden: { tiles: s.garden ?? [] }, plants: [], harvestedFragmentIds: [], lastTickAt: Date.now(), settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 }, }), }; export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number } { let current = payload; let v = fromVersion; while (v < CURRENT_SCHEMA_VERSION) { const next = v + 1; const fn = migrations[next]; if (!fn) throw new Error(`No migration registered for v${v} → v${next}`); current = fn(current); v = next; } return { payload: current, toVersion: v }; } ``` **Why ordered chain over registry-with-named-functions:** The chain is the most testable shape — each migration is a pure function with a known input schema and known output schema; Vitest tests each migration in isolation (`expect(migrations[1]({garden: []})).toEqual({...v1 shape})`). Phase 4's real `migrate_v1_to_v2` slots in by adding `migrations[2] = ...`. **Pre-migration snapshot rule:** Before invoking `migrate()` on a load, write the original envelope to the `snapshots` object store keyed by `{schemaVersion, savedAt}`. Pruner trims to last 3 entries. See "Pattern 3" below. [CITED: shapez.io save/load post-mortem — https://deepwiki.com/tobspr-games/shapez.io/2.2-saveload-system; ARCHITECTURE.md Pattern 5] ### Pattern 3: Last-3 Pre-Migration Snapshots **What:** A second IndexedDB object store, `save_snapshots`, holds copies of envelopes from before any migration ran. Schema: ```typescript interface SnapshotEntry { id: string; // `${schemaVersion}-${savedAtIso}` schemaVersion: number; savedAt: string; // ISO8601 envelope: SaveEnvelope; } ``` **Retention rule:** On every snapshot write, query all entries, sort by `savedAt` desc, keep first 3, delete the rest. Synchronous logic inside one IDB transaction. **Phase scope:** Phase 1 only ships the *storage and pruning* layer + a Vitest test that `(write 5 snapshots) → (assert 3 remain, oldest 2 deleted)`. The "Restore previous save" UI button hooks into this in Phase 4. **Example:** ```typescript // src/save/snapshots.ts import { openSaveDB } from './db'; import type { SaveEnvelope } from './envelope'; const RETAIN = 3; export async function snapshot(envelope: SaveEnvelope): Promise { const db = await openSaveDB(); const tx = db.transaction('save_snapshots', 'readwrite'); const store = tx.objectStore('save_snapshots'); const savedAt = new Date().toISOString(); await store.put({ id: `${envelope.schemaVersion}-${savedAt}`, schemaVersion: envelope.schemaVersion, savedAt, envelope, }); // Prune const all = await store.getAll(); const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); const toDelete = sorted.slice(RETAIN); await Promise.all(toDelete.map((e) => store.delete(e.id))); await tx.done; } export async function listSnapshots(): Promise { const db = await openSaveDB(); const all = await db.getAll('save_snapshots'); return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); } ``` ### Pattern 4: Vite-Native Content Pipeline **What:** Use `import.meta.glob('/content/**/*.{md,yaml}', { eager: true, query: '?raw', import: 'default' })` to import every content file as a raw string at build time. Run gray-matter (for `.md`) or `yaml.parse` (for `.yaml`) on each, then validate via Zod schemas. Build fails on any Zod error because `loader.ts` runs at module-evaluation time and a thrown error propagates through Vite to the build process. **Why this shape over a separate script:** - One source of truth: the loader runs at build *and* dev (HMR works for free). - No emitted-JSON-file problem: compiled content is just JS values bundled by Vite. - Per-Season chunking arrives in Phase 2 by switching to `{ eager: false }` for Seasons 2–7 and `eager: true` for Season 1. - Vite already handles file watching, glob expansion, and TypeScript types. **Phase 1 scope:** Loader exists, schemas exist, but `/content/` is *empty* of real content. Demo schema (one tiny fragment in `/content/seasons/00-demo/fragments.yaml`) proves the round-trip and is removed in Phase 2. **Example:** ```typescript // src/content/schemas/fragment.ts import { z } from 'zod'; export const FragmentSchema = z.object({ id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/), season: z.number().int().min(1).max(7), body: z.string().min(1), }); export type Fragment = z.infer; export const SeasonContentSchema = z.object({ fragments: z.array(FragmentSchema), }); ``` ```typescript // src/content/loader.ts import grayMatter from 'gray-matter'; import { parse as parseYAML } from 'yaml'; import { z } from 'zod'; import { FragmentSchema, SeasonContentSchema } from './schemas'; // Vite handles glob; ?raw imports as string; eager makes the imports available synchronously. const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default', }) as Record; const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', { eager: true, query: '?raw', import: 'default', }) as Record; const fragmentsFromYaml = 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; }); const fragmentsFromMd = Object.entries(mdFiles).map(([path, raw]) => { const { data, content } = grayMatter(raw); const merged = { ...data, body: content.trim() }; const parsed = FragmentSchema.safeParse(merged); if (!parsed.success) { throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); } return parsed.data; }); export const fragments: Fragment[] = [...fragmentsFromYaml, ...fragmentsFromMd]; ``` **Ink files in Phase 1:** Per CONTEXT discretion + cheaper-path recommendation, **defer Ink compilation to Phase 2**. Phase 1 ships: 1. `npm install inkjs inklecate` so the deps are locked. 2. A `package.json` script `"compile:ink": "inklecate -o src/content/compiled-ink/ content/dialogue/*.ink || true"` that exists but operates on an empty directory (no-op). 3. `/content/dialogue/.gitkeep` so the directory is real. 4. **No Ink validation** in Phase 1 — Phase 2 adds it when the first `.ink` file is written. This is the minimum-viable shape: the *path* to compile Ink exists; nothing flows through it yet. Full Ink integration is Phase 2. ### Pattern 5: ESLint Boundary Rule (`eslint-plugin-boundaries`) **What:** Define each top-level `src/` directory as an "element type"; declare which types each type may import from. ```javascript // eslint.config.js (flat config, ESM) import boundaries from 'eslint-plugin-boundaries'; export default [ // ... template's existing config first ... { plugins: { boundaries }, settings: { '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}.tsx' }, { type: 'game', pattern: 'src/game/**' }, ], }, rules: { 'boundaries/element-types': ['error', { default: 'allow', rules: [ // The Phase-1 firewall (CORE-10): { from: ['sim'], disallow: ['render', 'ui'] }, ], }], }, }, ]; ``` **Why this over `no-restricted-paths`:** The element-type abstraction means that when Phase 4 adds `src/server/` (or any new tier), we add one element entry rather than enumerating new path-pair forbids. Cleaner, more declarative, future-proof. The rule above forbids only the exact CORE-10 firewall; everything else is `default: allow` — keeping Phase 1 honest about which constraints actually exist. **CI integration:** `npm run lint` in the GitHub Actions workflow (or local pre-commit). Failure exits non-zero. Verify with one deliberately-bad file in tests (a unit test that asserts `eslint --rulesdir ... src/sim/__test_violation__/...` exits with the boundary error — only kept in the test fixture, removed from production builds). [VERIFIED: eslint-plugin-boundaries 6.0.2 README, 2026-05-08] ### Pattern 6: Provenance Sidecar Validator **What:** A 30-line standalone Node script that walks `assets/`, verifies that every non-sidecar file has a sibling `.provenance.json` parseable against a Zod schema, with the exception of `assets/__samples__/refused/`. ```javascript // scripts/validate-assets.mjs import { readdir, readFile, stat } from 'node:fs/promises'; import { join, extname, basename, dirname } from 'node:path'; import { z } from 'zod'; const ProvenanceSchema = z.object({ model_id: z.string().min(1), checkpoint_hash: z.string().min(1), prompt: z.string().min(1), seed: z.union([z.string(), z.number()]), sampler: z.string().min(1), params: z.record(z.string(), z.unknown()), }); const ASSETS = 'assets'; const REFUSED = ['assets/__samples__/refused']; async function* walk(dir) { for (const entry of await readdir(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) yield* walk(path); else yield path; } } const errors = []; for await (const path of walk(ASSETS)) { if (REFUSED.some((r) => path.replaceAll('\\', '/').startsWith(r))) continue; if (path.endsWith('.provenance.json')) continue; if (basename(path) === '.gitkeep') continue; const sidecar = path + '.provenance.json'; // e.g. foo.png.provenance.json // OR .provenance.json — pick one convention; document in CONTEXT for Phase 2. try { const raw = await readFile(sidecar, 'utf8'); const parsed = ProvenanceSchema.safeParse(JSON.parse(raw)); if (!parsed.success) errors.push(`${path}: ${parsed.error.message}`); } catch (e) { errors.push(`${path}: missing or unreadable provenance sidecar (${sidecar})`); } } if (errors.length) { console.error('[provenance] validation failed:'); errors.forEach((e) => console.error(' ' + e)); process.exit(1); } console.log('[provenance] all assets carry valid provenance.'); ``` **Wire it up:** ```json // package.json "scripts": { "validate:assets": "node scripts/validate-assets.mjs", "lint": "eslint .", "test": "vitest run", "ci": "npm run lint && npm run test && npm run validate:assets && npm run build" } ``` **Sidecar naming convention decision:** Use `.provenance.json` (e.g. `garden-soil-01.png.provenance.json`) — this keeps the sidecar adjacent in directory listings, makes it grep-friendly, and avoids ambiguity for files with the same stem but different extensions. **Refused-sample test:** Commit `assets/__samples__/refused/no-provenance.png` (an actual image file, no sidecar). Then write a Vitest test that *temporarily moves* the refused asset out of `__samples__/refused/` and asserts the validator script exits non-zero — proving the gate works. Restore the file in the test teardown. **Should this also run in Vite dev?** No. Per CONTEXT D-03, the gate is CI-only. Running it in dev would slow down `npm run dev` startup; provenance churn during development is normal and shouldn't block iteration. ### Anti-Patterns to Avoid - **Adding a curator workflow doc / pre-commit hook / two-stage promotion directory** — explicitly rejected by CONTEXT D-03. Sidecar + CI walker is the entire pipeline. - **Pre-allocating save schema slots for Roothold / currentSeason / storyFlags** — explicitly rejected by CONTEXT D-04. v1 schema contains only what Phase 2 will write. - **Compiling `.ink` files in Phase 1** — explicitly out of scope. The path exists; the runtime doesn't. - **Lint rule on UX strings** — explicitly rejected by CONTEXT D-07. Anti-FOMO is a review document, not a code rule. - **A separate `npm run build:content` Node script** — Vite-native is simpler. Refactor only if Phase 5+ asset volume forces it. - **BigQty wrapper / Zustand store / tick scheduler / Howler bootstrap / AudioContext gesture gate** — all explicitly Phase 2. - **Running the asset validator in Vite dev** — CI only. Don't burden the dev loop. - **Hand-rolling CRC32 / a custom hash** — `crc-32` is dependency-free at 426k ops/sec. Don't NIH this. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | IndexedDB transactions | `indexedDB.open` + manual `onupgradeneeded` + Promise wrappers | `idb` (`openDB`, `db.put`, `db.get`) | Idb is 1.19kB brotli'd. Hand-rolling promise wrappers around the request-event API loses ~200 lines, has subtle bugs around transaction lifecycle, and reinvents what's already standard. | | Save compression | DIY LZ77 / DIY Base64 wrappers | `lz-string` (`compressToUTF16`, `compressToBase64`) | The library is 30k downloads/week, battle-tested in incremental-game saves for ~10 years. UTF-16 storage avoidance + URL-safe variants are subtle. | | Schema validation | Hand-written `if (typeof x.id !== 'string')` checks | `zod` | The whole point of Zod is type-safe runtime validation. Doing it by hand on save envelopes / content / provenance is error-prone and tedious. | | Frontmatter parsing | Hand-rolled `--- ... ---` parser | `gray-matter` | Edge cases (escaped delimiters inside YAML strings, multi-document YAML) bite. | | YAML parsing | Hand-rolled / `JSON.parse` of a yaml-as-json variant | `yaml` (`eemeli/yaml`) | YAML 1.2 spec is gnarly. `yaml` is the reference implementation. | | ESLint architectural boundaries | Pre-commit grep / shell scripts | `eslint-plugin-boundaries` | IDE integration, ESLint flat-config compatibility, element-type abstraction. | | CRC32 | Hand-rolled bit-twiddling | `crc-32` (SheetJS) | Polynomial tables and seed handling are easy to get subtly wrong; CRC-32 package is dependency-free and tiny. | | Glob walking the filesystem | `glob` / `fast-glob` / DIY | `node:fs/promises` `readdir({withFileTypes: true, recursive: true})` for the asset validator; `import.meta.glob` for content | Node 20+ supports recursive readdir natively. No new dependency needed for the 30-line validator. | | Test runner | Hand-rolled assertion library | `vitest` | Vite-native, parallel by default, ESM-first. | | E2E driver | Puppeteer / DIY | `@playwright/test` | Phase 1 install only — first spec lands in Phase 2. | **Key insight:** Phase 1 is *infrastructure plumbing*. The temptation is to over-engineer abstractions because there's no feature work to ground them. Resist. Every recommendation above is the *thinnest* shape that satisfies the success criterion. The user pushed back on overengineering before context was even gathered — the planner will too. ## Common Pitfalls ### Pitfall 1: `import.meta.glob` requires literal patterns **What goes wrong:** Calling `import.meta.glob(somePathVariable)` silently fails. The glob string MUST be a string literal at the call site. **Why it happens:** Vite's plugin walks the AST at build time and can't resolve runtime expressions. **How to avoid:** Always inline the glob as a literal. If multiple patterns are needed, write multiple glob calls and merge the results. **Warning signs:** A "Could only use literals" error at build time. [CITED: vite docs https://vite.dev/guide/features#glob-import] ### Pitfall 2: `navigator.storage.persist()` returns false on iOS Safari most of the time **What goes wrong:** First-save attempt to call `persist()` returns false on Safari/iOS unless the app has been added to home screen as a PWA or has been heavily interacted with. Player loses 30 days of save progress on Safari's periodic eviction (WebKit bug 266559). **Why it happens:** Safari's persistence-grant heuristic is opaque and conservative. Apple has explicitly documented "iOS provides no persistent storage guarantee" outside PWA context. **How to avoid:** - Always call `navigator.storage.persist()` on first save and check the result. - If `false`, write a sentinel flag (`saveIsBestEffort: true`) into the save envelope. - Surface a UX message at next opportunity (Phase 2+ writes the actual UI): *"Your browser is keeping this save on a best-effort basis. Export a backup any time from Settings."* - The Base64 export/import (CORE-09) is the resilience guarantee against this — make sure it lands in Phase 1 even though no UI button surfaces it yet. **Warning signs:** Telemetry showing save-loaded count < expected on Safari (Phase 8 polish, not Phase 1 concern — but the affordance must exist). [CITED: MDN https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist; PITFALLS.md #8] ### Pitfall 3: JSON key ordering breaks checksums across runs **What goes wrong:** `JSON.stringify({a: 1, b: 2})` and `JSON.stringify({b: 2, a: 1})` produce the same string in V8 today, but **this is not spec-guaranteed across engines** (and Object.fromEntries iteration order is technically insertion order). If a Phase 4 migration somehow re-creates an object with different key order, the checksum changes even though the data is identical. **Why it happens:** Default `JSON.stringify` doesn't canonicalize. **How to avoid:** Implement `canonicalJSON()` that sorts object keys recursively before stringifying, as shown in Pattern 1. Use it everywhere checksums are computed. **Warning signs:** A migration test fails with "expected X, got Y" where X and Y are visually identical JSON. ### Pitfall 4: Vite ESM build of Phaser 4 silently breaks if peer versions misalign **What goes wrong:** Phaser 4.0.0 had ESM build issues that 4.1.0 fixed. Mixing the wrong React version with the official template can produce runtime "cannot read property X of undefined" with no clear stack trace. **Why it happens:** The Phaser 4.1.0 release notes explicitly call out "important fixes for the ESM module build." **How to avoid:** Use the template-installed versions exactly. Don't manually upgrade Phaser without checking the release notes; pin Phaser to `^4.1.0`. **Warning signs:** Game scaffold loads white screen with console errors after a dependency update. [CITED: Phaser v4.1.0 "Salusa" release notes — https://phaser.io/news/2026/04/phaser-4-1-0-salusa-release] ### Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread **What goes wrong:** `LZString.compressToBase64` is synchronous; on a 5MB save (Season 5+ scope) it can block the main thread for ~50ms+, dropping a frame mid-cello-ambient and ruining the watercolor mood. **Why it happens:** lz-string predates async APIs. **How to avoid:** Phase 1's saves are tiny (<10KB) so this isn't a real concern *yet*. Document the eventual mitigation: move save serialization into a Web Worker if Season 5+ saves grow past ~1MB. Don't build the worker now (premature) but **do** wrap save writes in `src/save/codec.ts` so the swap is one file later. **Warning signs:** Frame drops on save in a Season-5 simulation profile (Phase 8 concern). [CITED: PITFALLS.md #8 perf table] ### Pitfall 6: ESLint flat-config plugin imports break with mixed CJS/ESM template baseline **What goes wrong:** The Phaser template ships an `.eslintrc.cjs` baseline (legacy config). Adding `eslint-plugin-boundaries` via the new flat-config `eslint.config.js` alongside the legacy file confuses ESLint 9+. **Why it happens:** ESLint 9 deprecated legacy config; templates may still ship the older shape during transition. **How to avoid:** Migrate the template's `.eslintrc.cjs` to a single `eslint.config.js` (flat) at the start of Phase 1. Don't run both. The migration is mechanical (`@eslint/migrate-config` automates it). **Warning signs:** ESLint runs but boundaries rule never fires; or runs twice with conflicting messages. ### Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry **What goes wrong:** It's tempting to write a "round-trip" test that wraps and unwraps the same object without ever calling `migrate()`. That tests envelope integrity but doesn't validate the migration framework. **Why it happens:** Convenience. **How to avoid:** The required Vitest assertions for CORE-07 are: 1. `migrate({garden: []}, 0)` returns `{payload: {...v1 shape...}, toVersion: 1}`. 2. Calling `migrate(payload, 1)` is a no-op. 3. Calling `migrate(payload, -1)` or `migrate(payload, 99)` throws. 4. The full round-trip — write v0 envelope to IDB, load it, migrate, validate via Zod — passes. 5. Snapshot retention test: write 5 successive saves, list snapshots, assert exactly 3 remain in newest-first order. **Warning signs:** Test coverage is high but no test actually invokes `migrations[1]`. ## Code Examples ### Save round-trip (Phase 1's load-bearing test) ```typescript // src/save/round-trip.test.ts import { describe, it, expect } from 'vitest'; import { wrap, unwrap } from './envelope'; import { migrate, CURRENT_SCHEMA_VERSION } from './migrations'; import LZString from 'lz-string'; describe('save round-trip', () => { it('synthetic v0 envelope migrates, round-trips through Base64, and validates', () => { // Pretend a player had an old v0 save lying around. const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] }; const v0Envelope = { schemaVersion: 0, payload: v0Payload, checksum: 'deadbeef' /* dummy */ }; // Export const exported = LZString.compressToBase64(JSON.stringify(v0Envelope)); expect(exported.length).toBeGreaterThan(0); // Import (in a fresh "browser") const imported = JSON.parse(LZString.decompressFromBase64(exported)!); expect(imported.schemaVersion).toBe(0); // Migrate const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion); expect(toVersion).toBe(CURRENT_SCHEMA_VERSION); expect(payload).toMatchObject({ garden: { tiles: expect.any(Array) }, lastTickAt: expect.any(Number), }); // Re-wrap with current version + valid checksum const v1Envelope = wrap(payload, toVersion); expect(unwrap(v1Envelope)).toEqual(payload); }); }); ``` ### Persist API call with respectful surfacing ```typescript // src/save/persist.ts export interface PersistResult { granted: boolean; apiAvailable: boolean; } export async function requestPersistence(): Promise { if (!('storage' in navigator) || !('persist' in navigator.storage)) { return { granted: false, apiAvailable: false }; } try { const granted = await navigator.storage.persist(); return { granted, apiAvailable: true }; } catch { return { granted: false, apiAvailable: true }; } } // Caller (Phase 2 wires UI; Phase 1 ships function only): // const result = await requestPersistence(); // if (!result.granted) { // // store flag in save envelope; surface in Phase 2 settings UI: // // "Your browser is keeping this save on a best-effort basis. // // Export a backup any time from Settings." // } ``` ### Provenance sidecar example ```json // assets/north-stars/garden-soil-01.png.provenance.json { "model_id": "claude-3.7-sonnet@2025-12 / sd-xl-watercolor-lora-v3", "checkpoint_hash": "sha256:abcd1234...", "prompt": "A walled cottage garden at dusk in late autumn, watercolor wash, real but slightly wrong wildflowers, golden palette, no fantasy elements", "seed": 0, "sampler": "DPM++ 2M Karras", "params": { "steps": 30, "cfg_scale": 7.5, "width": 1024, "height": 1024, "lora_weight": 0.8, "notes": "Phase 1 north-star reference image #01 of 20. Hand-curated; reroll-allowed via stored seed." } } ``` ### Doctrine doc outline — `anti-fomo-doctrine.md` Recommended structure (principle-level, ~1 page): ```markdown # Anti-FOMO Doctrine This document is referenced at every UX, monetization, and copy review. It enumerates mechanics this game does not use, with the reason for each, so the answer to a "should we add X?" question is in writing rather than relitigated. ## Banned Mechanics | Mechanic | Why Banned | |----------|------------| | Daily login bonuses | Presence is not a debt the game collects. | | Login streaks | Skipping a day is allowed, even encouraged. | | Limited-time content (events that disappear) | The game's premise is *what persists*. | | Loss-aversion copy ("you'll lose your X") | Tonally incompatible with cozy/contemplative. | | Re-engagement push notifications | Memory Storms (opt-in) are the *only* allowed notification class. | | Rewarded ads | Anti-cozy; tonally incoherent. | | Visible countdown timers in core UI | The cello is the timer. | | "Don't miss out" / "limited time" / "only X hours left" copy | Bannable phrases. | ## Allowed Engagement - Memory Storm opt-in notifications. - "While you were away" letter on return (in Lura's voice, not a stat dump). - Tab-title bloom indicator (UX-09, Phase 8). ## Review Checklist When reviewing any UX/copy/monetization change, ask: 1. Does this create urgency around presence rather than around content? 2. Does this frame absence as loss? 3. Would removing this from the game make it less *cozy*? If yes to any → reject or rewrite. ## Source Documents - PROJECT.md "Out of Scope" — anti-features - REQUIREMENTS.md UX-13, "Out of Scope" table (rows: gacha, daily login, streaks, limited-time, energy/stamina, rewarded ads, push spam) - CLAUDE.md "Hard Thematic Constraints" - .planning/research/PITFALLS.md #9 ``` ### Doctrine doc outline — `season-7-end-state.md` Recommended structure (principle-level, ~1 page): ```markdown # Season 7 End-State Design (Principle-Level) This document answers the question that ends ROADMAP.md Phase 7's success criterion #4: *"the finite Roothold ceiling from Phase 4 has held the line, and the game has ended the way A Dark Room and Universal Paperclips ended."* ## What does *rest state* mean? The rest state is the post-credits configuration the player can return to indefinitely without grinding. Concretely: - No new fragments are added to the pool. All authored content has been delivered. - No new currency tiers unlock. - The garden continues to render and respond to clicks. Plants can still be planted and harvested, but harvests yield re-readable previously-collected fragments — nothing new. - The Pale has receded. The Heartsoil expands beyond the garden walls. Lura's arc has resolved. The Archivist's question has been answered (in the player's binary choice from Season 7's final scene). - The cello and ambience continue. The world is *quiet*, *finite*, *understood*. This is not "endgame content." It is **rest**. ## What is the finite Roothold ceiling tied to? Roothold's ceiling is anchored in the **count of authored fragments and the count of Seasons**, not in an arbitrary number. The ceiling is: *one cannot accumulate more Roothold than the player has actually understood, and what the player can understand is bounded by what the writer has actually written.* Concrete tie: - Roothold gain per Season is gated to a hard cap proportional to the fragment count of that Season + a small contribution from Roothold-relevant story beats. - Total Roothold cap = Σ(per-Season caps). - Phase 4 enforces this cap when it implements `migrate_v1_to_v2` and the prestige state machine. Phase 7 verifies the ceiling holds through full play. ## What tonal register does the coda live in? - **Warm**, not pyrrhic. The garden persists *because* you tended it; this is earned redemption, not survival. - **Quiet**, not climactic. The cello does not crescendo. It rests. - **Specific**, not abstract. The final visible state is a *real* garden — the one this player built — viewed in soft dawn-silver light. - **Final**, not infinite. There is no "Season 8." There is no New Game+. The Pale receded *here*, in *this* garden. ## What this document is NOT - The text of the Season 7 binary-choice scene (authored Phase 7). - The text of either ending paragraph (authored Phase 7). - Lura's final line (authored Phase 7). - The credits/coda screen visual treatment (designed Phase 7). - The tonal register or shape of the final fragments themselves. This document is *the principle the economy obeys, the writer obeys, and the Phase 7 designer obeys* — not the implementation of any of those. ## Source Documents - PROJECT.md core value — "what survives is what you understood" - REQUIREMENTS.md SEAS-04, SEAS-09, SEAS-10, STRY-08 - ROADMAP.md Phase 7 - .planning/research/PITFALLS.md #1 ``` ## Runtime State Inventory (Greenfield project — no prior runtime state to migrate.) | Category | Items Found | Action Required | |----------|-------------|------------------| | Stored data | None — first commit; no databases, no IndexedDB writes, no on-disk state | None | | Live service config | None | None | | OS-registered state | None | None | | Secrets/env vars | None — no `.env` files, no API keys yet | None | | Build artifacts | None — no `node_modules/`, `dist/`, or compiled output yet | First `npm install` will create `node_modules/`; `.gitignore` from template covers it | **Nothing to migrate** — Phase 1 is the first phase that produces any artifacts. ## Common Pitfalls (CI / Tooling Specific) (See "Common Pitfalls" section above for the full list of seven pitfalls. This section additionally documents three CI-tooling traps unique to first-phase infrastructure work.) ### CI Pitfall A: GitHub Actions caching `node_modules` instead of `~/.npm` **What goes wrong:** Cache restores `node_modules/` directly; subsequent `npm install` doesn't refresh transitive deps and silently runs against stale versions. **How to avoid:** Use `actions/setup-node@v4` with `cache: 'npm'` (caches `~/.npm` based on `package-lock.json`) — never cache `node_modules/` directly. ### CI Pitfall B: Vitest in CI doesn't fail the build on uncaught test errors **What goes wrong:** `vitest run` exits 0 if no tests exist (Phase 1 ships only 2-3 test files); CI passes a "no tests" run as success. **How to avoid:** Add `--passWithNoTests=false` to the vitest CLI call in CI so empty runs are flagged. ### CI Pitfall C: ESLint flat-config + `--max-warnings 0` in template **What goes wrong:** Template may default to allowing warnings; the boundaries rule fires as a warning instead of an error. **How to avoid:** Set `boundaries/element-types` severity to `error` (numeric `2` or string `'error'`) explicitly. Add `npm run lint -- --max-warnings 0` to the CI script. ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Phaser 3 + React via webpack | Phaser 4 official template (Vite + React + TS) | April 2026 (Phaser 4 GA) | The official template is the path. Don't pick Phaser 3 in 2026. | | ESLint legacy config (`.eslintrc.*`) | ESLint flat config (`eslint.config.js`) | ESLint 9 (2024) | Migrate the template's legacy file at start of Phase 1. | | Zod 3.x | Zod 4.x | Zod 4 GA late 2025 | Use 4.x for new code; STACK.md's "3.23+" is conservative; 4.4.3 is current. | | Vite 6 (current LTS) | Vite 8 (Rolldown) | 2026 | Phaser 4 template may install Vite 6 or 7. Don't aggressively upgrade — accept template version. | | `js-yaml` | `yaml` (eemeli/yaml) | 2023+ | YAML 1.2 spec compliance; better TypeScript types. | | Vitest workspace config | Vitest projects config | Vitest 3.2+ (2025) | Workspace is deprecated. Phase 1 doesn't need projects; single config is fine. | **Deprecated/outdated:** - ESLint legacy config — migrate at scaffold time. - `js-yaml` — use `yaml` for new code. - Phaser 3 starter templates — use Phaser 4 official template. ## Assumptions Log > Every claim in this document is either [VERIFIED] against a tool/registry, [CITED] from a > linked source, or [ASSUMED]. Below are the [ASSUMED] claims that the planner / discuss-phase > may want to validate with the user before locking. | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | The Phaser 4 official template (`npm create @phaserjs/game@latest`) installs Vite 6 or 7 (not yet 8). | Standard Stack | Low — the planner can verify by running the scaffold once. If Vite 8, no behavior change needed. | | A2 | Sidecar naming convention `..provenance.json` (vs. `.provenance.json`) is right for Phase 1. | Pattern 6 | Low — convention can be flipped in 5 minutes if user has preference. Document choice in CONTEXT for Phase 5. | | A3 | `JSON.stringify` with sorted keys is canonical-enough for CRC32 (vs. a stricter canonical-JSON library like `json-stable-stringify`). | Pattern 1 | Low — adding `json-stable-stringify` is a one-line change if a Phase 4 migration ever produces non-stable output. | | A4 | The synthetic v0 schema `{garden: []}` is a sufficiently simple prior for the demo migration. | Pattern 2 | Very low — CONTEXT D-05 explicitly endorses any synthetic v0 shape. | | A5 | The 10–20 north-star images can be produced as part of Phase 1 execution without a vendor lock-in (CONTEXT D-02). | Standard Stack | Medium — depends on whether the user has access to *some* AI image generation tool during Phase 1 execution. If not, a placeholder commit (10 hand-painted PNGs from a free reference + sidecars filled in honestly) satisfies AEST-08/09 minimally. | | A6 | The `inkjs` + `inklecate` install in Phase 1 (with no compilation actually happening) is a "cheap path forward" the user prefers over deferring the install entirely. | Standard Stack | Very low — installing two deps that aren't used is harmless; deferring them leaves a Phase 2 task. Either is fine. | | A7 | Phase 1's Vitest config can be a 5-line minimal `vitest.config.ts` with Node environment (not jsdom) — since save tests don't touch DOM. | Validation Architecture | Very low — jsdom is needed for Phase 2 React-component tests, not Phase 1. | If this list is non-empty after planning, the planner should surface these for user confirmation before locking the plan. None of them block research. ## Open Questions 1. **Should the canonical-JSON implementation be `json-stable-stringify` or hand-rolled?** - What we know: `JSON.stringify` is non-canonical by spec; sorted-key recursion is the standard fix. - What's unclear: Does Phase 4+ need stronger canonicalization (Unicode normalization, number formatting)? - Recommendation: Hand-roll the sorted-key recursion in Phase 1 (5 lines). Add `json-stable-stringify` as a dependency only if Phase 4+ surfaces a concrete reason. 2. **Does the asset validator need a `.provenance.json` schema versioning field for Phase 5 vendor migration?** - What we know: CONTEXT D-02 defers vendor pinning to Phase 5. - What's unclear: When Phase 5 picks a vendor, will the schema need to change (e.g., add `pipeline_version`, `human_curated_at`)? - Recommendation: Add an optional `provenance_schema_version: number` field now (default `1`); Phase 5 bumps it if the schema evolves. This is one optional field — cheap insurance. 3. **Should `src/sim/`, `src/render/`, `src/ui/`, `src/audio/`, `src/store/` be created with `.gitkeep` or with stub `index.ts` files?** - What we know: The directories must exist for ESLint boundaries patterns to match. - What's unclear: `.gitkeep` is convention; `index.ts` would let ESLint also lint imports through them. - Recommendation: `.gitkeep` for now (simpler). Phase 2 replaces with real entry points. 4. **Does Phase 1 need a CI workflow file at all (e.g., `.github/workflows/ci.yml`), or just `package.json` scripts?** - What we know: Solo dev, the user has rejected ceremonial workflows. - What's unclear: Is GitHub Actions in scope for Phase 1, or is "CI" satisfied by `npm run ci` running locally / in the dev's preferred CI? - Recommendation: Ship a 20-line `.github/workflows/ci.yml` that runs `npm run ci` on PR + push to main. This is minimum-viable CI, not ceremony. If user prefers no GitHub Actions, swap to a simpler CI of their choice; the `ci` script is platform-agnostic. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Node.js (≥20 for `recursive: true` readdir, ≥22 ideal for crypto.hash native) | All build steps | ✓ | 24.14.1 | — | | npm | All install steps | ✓ | 11.11.0 | yarn / pnpm acceptable but template defaults to npm | | git | Source control + commit_docs | ✓ | 2.51.0 | — | | Modern browser (Chrome/Firefox/Safari/Edge last 2) for IndexedDB testing | CORE-04 verification | Assumed ✓ (dev machine) | — | Use Vitest's happy-dom for headless IndexedDB if no real browser available, but Playwright will need real browsers for Phase 2 PIPE-07 | | Image generation tool (any) for north-star reference set | AEST-08 / AEST-09 / D-01 | Unknown — depends on user | — | If no tool available: commit 10 hand-painted reference images (or licensed-CC-BY photographs of real cottage gardens) with provenance fields filled in honestly (`model_id: "human"`, `prompt: "n/a"`, etc.). Schema accepts arbitrary `model_id` strings. | **Missing dependencies with no fallback:** None known. All Phase 1 work runs on Node + npm + a browser. **Missing dependencies with fallback:** Image generation tool — see above. Provenance schema is permissive enough that "honest human-painted" entries are valid. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest 4.1.5 (verified 2026-05-08) | | Config file | `vitest.config.ts` (Wave 0 deliverable; minimal — no jsdom in Phase 1) | | Quick run command | `npm test` (alias for `vitest run`) | | Full suite command | `npm run ci` (lint + test + validate-assets + build) | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | CORE-04 | IndexedDB save round-trips with localStorage fallback path | unit | `npx vitest run src/save/db.test.ts` | ❌ Wave 0 | | CORE-05 | `requestPersistence()` returns `{granted, apiAvailable}` shape and handles missing API | unit | `npx vitest run src/save/persist.test.ts` | ❌ Wave 0 | | CORE-06 | `wrap()` produces valid envelope; `unwrap()` rejects checksum mismatch | unit | `npx vitest run src/save/envelope.test.ts` | ❌ Wave 0 | | CORE-07 | `migrate({garden:[]}, 0)` produces v1 shape; `migrations[1]` is invoked exactly once | unit | `npx vitest run src/save/migrations.test.ts` | ❌ Wave 0 | | CORE-08 | After 5 successive `snapshot()` calls, exactly 3 newest entries remain in `save_snapshots` store | unit | `npx vitest run src/save/snapshots.test.ts` | ❌ Wave 0 | | CORE-09 | Base64 export → import → migrate → unwrap yields original payload | unit (round-trip) | `npx vitest run src/save/round-trip.test.ts` | ❌ Wave 0 | | CORE-10 | `src/sim/` importing from `src/render/` produces ESLint error | static-analysis (CI) | `npm run lint` (with a fixture file in `src/sim/__test_violation__/` that *should* error, then asserted via a Vitest snapshot of `eslint --rulesdir` output) | ❌ Wave 0 | | CORE-01 | Game scaffold `npm run build` produces a valid bundle | smoke (manual + Phase 2 Playwright) | `npm run build` (manual verification in Phase 1; Phase 2 PIPE-07 adds Playwright load-time spec) | manual-only in Phase 1 | | PIPE-01 | Demo content file with deliberate schema violation fails the build | unit (build-time) | `npx vitest run src/content/loader.test.ts` (mocks the glob) + manual `npm run build` with bad fixture | ❌ Wave 0 | | PIPE-03 | Asset validator script exits non-zero on a fixture missing provenance | integration | `node scripts/validate-assets.mjs` (fixture-driven Vitest test that temporarily creates `assets/__test__/no-provenance.png`, asserts script exits 1, cleans up) | ❌ Wave 0 | | PIPE-05 | `.planning/anti-fomo-doctrine.md` and `.planning/season-7-end-state.md` exist with required H2 sections | doc lint (Vitest filesystem assertion) | `npx vitest run scripts/doctrine.test.ts` | ❌ Wave 0 | | PIPE-06 | All save migration tests run on every CI build | meta — verified by CI script | `npm run ci` includes `npm test` | ❌ Wave 0 | | AEST-08 / AEST-09 | All assets in `assets/` (excluding `__samples__/refused/`) have valid provenance sidecars | integration | `node scripts/validate-assets.mjs` (covered by PIPE-03 test) | ❌ Wave 0 | | STRY-09 | (No automated test in Phase 1 — establishes `/content/` convention only; Phase 2+ enforces by code review since CONTEXT D-07 rejects lint rules on UX strings) | manual-only | — | n/a | | UX-13 | (No automated test — doctrine doc is enforced by review per CONTEXT D-07) | manual-only | — | n/a | ### Sampling Rate - **Per task commit:** `npm test` (vitest run, ~5 seconds for the entire Phase 1 test set). - **Per wave merge:** `npm run ci` (lint + test + validate-assets + build, ~30 seconds). - **Phase gate:** `npm run ci` green, plus manual smoke that `npm run dev` opens the Phaser scaffold in a browser. `gsd-verify-work` consumes the green CI run as evidence. ### Wave 0 Gaps All test infrastructure must be created in Wave 0 (no test files exist in greenfield repo): - [ ] `vitest.config.ts` — minimal config (test environment: node) - [ ] `playwright.config.ts` — installed only, no specs in Phase 1 - [ ] `src/save/db.test.ts` — covers CORE-04 (idb open + put + get; happy-dom for IndexedDB shim) - [ ] `src/save/persist.test.ts` — covers CORE-05 - [ ] `src/save/envelope.test.ts` — covers CORE-06 - [ ] `src/save/migrations.test.ts` — covers CORE-07 - [ ] `src/save/snapshots.test.ts` — covers CORE-08 - [ ] `src/save/round-trip.test.ts` — covers CORE-09 (and integrates CORE-04, 06, 07) - [ ] `src/save/checksum.test.ts` — covers checksum determinism (canonical JSON keys) - [ ] `src/content/loader.test.ts` — covers PIPE-01 (mocked `import.meta.glob`) - [ ] `scripts/doctrine.test.ts` — covers PIPE-05 (asserts both doctrine docs exist with required H2 headings) - [ ] `scripts/validate-assets.test.ts` — covers PIPE-03 / AEST-08 / AEST-09 - [ ] `eslint.config.js` — ESLint flat config with `eslint-plugin-boundaries` + boundary rule for `src/sim/` ↔ `src/render/`/`src/ui/` - [ ] `src/sim/__test_violation__/violator.ts` (test fixture) — deliberate boundary violation that the lint rule MUST flag; lint test asserts ESLint output contains the expected error - [ ] `.github/workflows/ci.yml` (or equivalent) — runs `npm run ci` on PR + push - [ ] Framework install: `npm install -D vitest@^4 @vitest/ui happy-dom` — `happy-dom` is needed for IndexedDB in node-side Vitest; lighter than jsdom ## Security Domain `security_enforcement` is not explicitly set in `.planning/config.json`; treat as enabled. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | no | No auth in Phase 1 (or v1 — local-first save model). Cloud sync is v2. | | V3 Session Management | no | No sessions in Phase 1. | | V4 Access Control | no | Single-player; no multi-tenant data. | | V5 Input Validation | yes | Save envelope validated by Zod on every load (rejects malformed Base64 import, malformed envelope shape). Provenance sidecar validated by Zod. Content files validated by Zod at build. | | V6 Cryptography | partial | CRC32 is not a cryptographic hash; it's a corruption-detection checksum. **Saves are not authenticated** — a player editing their save is fine (single-player, no leaderboards, no monetization gates in Phase 1). Document explicitly: this is by design, not by oversight. PITFALLS.md "Security/Integrity Mistakes" already endorses this. | | V7 Error Handling | yes | Save load failures must surface the recovery option (CORE-06) — not silent reset. Phase 1 ships the error class + handler shape; Phase 2 wires the UI. | | V8 Data Protection | partial | Saves contain no PII in Phase 1 (just garden state); even player-chosen names are not collected (no Keeper name per STRY-07). | | V14 Configuration | yes | `npm audit` should be clean; pinned versions; no environment-secret leak in repo. | ### Known Threat Patterns for `Phaser 4 + React + IndexedDB + lz-string` stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Save tampering (player edits Base64 export) | Tampering | Acceptable — single-player; if Phase 2+ economy values become interesting to tamper, document explicitly that we don't defend against client-side editing. CRC32 detects *corruption* (lossy storage, transmission errors), not adversarial tampering. | | Malformed Base64 import (DoS via huge inflated string) | DoS | `lz-string.decompressFromBase64` has bounded output for bounded input; additionally, validate payload size before decompressing (cap at e.g. 50MB). | | Cross-origin import from URL params (open future risk if save-via-link added) | Tampering / Spoofing | Phase 1 has no URL-import; flag for Phase 4+ if added: import flow must require explicit user confirmation, never auto-load. | | `npm install` supply-chain (e.g., a transitive dep gets compromised) | Tampering | `package-lock.json` committed; `npm audit` in CI; pin Phase 1 deps to caret ranges (caught by lockfile). | | Provenance sidecar spoofing (a malicious contributor adds an asset with a fabricated provenance) | Spoofing | Single-developer project in Phase 1; not a real threat. Phase 8+ if external contributors are added: provenance includes a `human_reviewed_by` field signed by the curator. Out of scope for Phase 1. | | Path traversal via `import.meta.glob` (a malicious content file with `../../` in frontmatter) | Tampering | Vite glob expansion is at build time; no runtime user input. Not exploitable. Validation step never resolves paths from frontmatter values. | ### Phase 1 explicit security non-goals (documented for clarity) - **Save authentication** — by design. Player tampering with their own save is acceptable in a contemplative single-player game. - **Cloud sync security** — out of v1 scope. - **Multi-user data isolation** — single-player; no isolation needed. - **Auth / session tokens** — none in v1. ## Project Constraints (from CLAUDE.md) The following directives from `CLAUDE.md` constrain Phase 1 implementation. The planner must verify the plan honors each: | Directive | Phase 1 Application | |-----------|---------------------| | TypeScript strict; no `any` in production code | `tsconfig.json` ships with `"strict": true`; save-layer code uses `unknown` + Zod parsing for untrusted inputs (env on load, Base64 import). | | Player-visible strings externalized in `/content/`, never hardcoded | Phase 1 establishes the convention by *not violating it*. No player-facing strings ship in Phase 1 source code (no UI yet). | | Memory fragment IDs are stable strings (`season3.canopy.lura_07.vignette`), never numeric | Zod schema in `src/content/schemas/fragment.ts` enforces with regex (`^season\d+\.[a-z0-9._-]+$`). | | Simulation modules are pure — no `Date.now()`, no `setInterval`, no DOM, no fetch | `src/sim/` is empty in Phase 1 (Phase 2+ fills); ESLint boundary rule is the structural enforcement. | | BigNumbers go through the typed `BigQty` wrapper around `break_eternity.js` | **Phase 2 deliverable, not Phase 1.** Do not pre-create `BigQty` per CONTEXT. | | Save format always carries `{schemaVersion, payload, checksum}`. Never serialize raw state | Pattern 1 above. | | New AI-generated assets must carry full provenance metadata + pass curation gate | Pattern 6 above. | | Tone — player-facing copy is warm, specific, intermittent, sometimes funny, sometimes devastating | No player copy in Phase 1; doctrine docs themselves can be functional — they are project-internal. | | `/content/` is at repo root, alongside `src/`, `assets/`, `.planning/` | CONTEXT D-11 confirms; structure above honors it. | | `/assets/` at repo root | CONTEXT D-12 confirms. | | No monorepo / no workspaces | CONTEXT D-12 confirms; single `package.json`. | ## Sources ### Primary (HIGH confidence) - [eslint-plugin-boundaries README on GitHub](https://github.com/javierbrea/eslint-plugin-boundaries/blob/master/README.md) — config patterns, element-types rule, flat-config example - [npm registry — verified 2026-05-08 for all package versions](https://www.npmjs.com/) — `npm view version` for phaser, react, vite, typescript, idb, lz-string, zod, vitest, @playwright/test, eslint-plugin-boundaries, crc-32, gray-matter, yaml, inkjs, inklecate, howler, break_eternity.js, zustand - [idb GitHub — jakearchibald/idb](https://github.com/jakearchibald/idb) — openDB API, upgrade callback, put/get shortcuts - [Phaser v4.1.0 "Salusa" release notes](https://phaser.io/news/2026/04/phaser-4-1-0-salusa-release) — version, ESM fixes, Layer rework - [Phaser official template — phaserjs/template-react-ts](https://github.com/phaserjs/template-react-ts) — root structure, src/ layout, package.json scripts, dependencies - [MDN — `navigator.storage.persist()`](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist) — return value semantics, `false` conditions - [Vite docs — features (`import.meta.glob`)](https://vite.dev/guide/features#glob-import) — eager mode, query/import options, literal-pattern requirement - [Zod docs — z.infer + safeParse](https://zod.dev/) — type derivation, runtime validation - [Vitest docs — projects (replaces workspace)](https://vitest.dev/guide/projects) — multi-config; not needed for Phase 1 single config ### Secondary (MEDIUM confidence) - [SheetJS/js-crc32 — crc-32 npm package](https://github.com/SheetJS/js-crc32) — performance benchmarks (~426k ops/sec), API - [Emanuele Feronato — Getting Started with Phaser 4 (April 2026)](https://emanueleferonato.com/2026/04/17/getting-started-with-phaser-4-vite-typescript-setup-using-the-official-create-game-app/) — `create-phaser-game` flow walkthrough - [Trivikr — benchmark-crc32](https://github.com/trivikr/benchmark-crc32) — comparative speed of CRC32 npm packages - [pieroxy.net — lz-string](https://pieroxy.net/blog/pages/lz-string/index.html) — `compressToBase64` / `decompressFromBase64` semantics, encoding rates ### Tertiary (LOW confidence — flagged for validation) - [Magicbell — PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — Safari 17 partial persistence support; verified by MDN secondarily - [DEV.to — Why Your IndexedDB Data Keeps Disappearing](https://dev.to/denyherianto/why-your-indexeddb-data-keeps-disappearing-1m0a) — Chrome storage-pressure eviction patterns - [WebKit Bug 266559 — Safari periodic eviction of LocalStorage and IndexedDB](https://bugs.webkit.org/show_bug.cgi?id=266559) — referenced in PITFALLS.md; not re-verified in this research session ## Metadata **Confidence breakdown:** - Standard stack: HIGH — every package version verified against npm registry on 2026-05-08; locked versions match user constraints from CONTEXT. - Architecture (save layer, content pipeline, ESLint, validator): HIGH — patterns verified against official docs (idb, Vite, eslint-plugin-boundaries, MDN); shape is conservative and minimum-viable per user's anti-overengineering directive. - Pitfalls: HIGH for stack-specific, MEDIUM for browser quirks (Safari persist behavior is fast-moving; verified against MDN today but worth re-confirming if Phase 1 execution drags past 60 days). - Doctrine doc outlines: MEDIUM — they are *suggestions* for principle-level structure; the user is the final arbiter of what "principle-level" means for The Last Garden. The shape is informed by the source documents the docs consolidate. - Validation architecture: HIGH — every Phase 1 success criterion maps to a concrete automated test command, with one exception (CORE-01 load-time, which is manual in Phase 1 and gets Playwright coverage in Phase 2 PIPE-07). **Research date:** 2026-05-08 **Valid until:** 2026-06-08 (30 days — stack is stable but Vite 8 / Phaser 4.x landings could rotate)