1190 lines
80 KiB
Markdown
1190 lines
80 KiB
Markdown
# 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>
|
||
## 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+).
|
||
|
||
</user_constraints>
|
||
|
||
<phase_requirements>
|
||
## 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 `<name>.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. |
|
||
|
||
</phase_requirements>
|
||
|
||
## 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<T = unknown> {
|
||
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<T = unknown> = z.infer<typeof SaveEnvelopeSchema> & { payload: T };
|
||
|
||
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
|
||
return {
|
||
schemaVersion,
|
||
payload,
|
||
checksum: crc32hex(canonicalJSON(payload)),
|
||
};
|
||
}
|
||
|
||
export function unwrap<T>(env: SaveEnvelope<unknown>): 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<number, Migration>` 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<number, Migration> = {
|
||
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<void> {
|
||
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<SnapshotEntry[]> {
|
||
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<typeof FragmentSchema>;
|
||
|
||
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<string, string>;
|
||
|
||
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
|
||
eager: true,
|
||
query: '?raw',
|
||
import: 'default',
|
||
}) as Record<string, string>;
|
||
|
||
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 `<name>.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 <basename-without-ext>.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 `<filename>.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<PersistResult> {
|
||
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 `<filename>.<ext>.provenance.json` (vs. `<filename>.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 <package> 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)
|