---
phase: 02
plan: 05
type: execute
wave: 2
depends_on: [02-01, 02-02, 02-03, 02-04]
files_modified:
- content/dialogue/season1/letter-from-the-garden.ink
- src/sim/offline/events.ts
- src/sim/offline/events.test.ts
- src/sim/offline/index.ts
- src/sim/garden/auto-harvest.ts
- src/sim/garden/auto-harvest.test.ts
- src/sim/garden/index.ts
- src/sim/garden/commands.ts
- src/save/migrations.ts
- src/save/index.ts
- src/ui/letter/Letter.tsx
- src/ui/letter/Letter.test.tsx
- src/ui/letter/letter-renderer.ts
- src/ui/letter/letter-renderer.test.ts
- src/ui/letter/index.ts
- src/ui/settings/Settings.tsx
- src/ui/settings/Settings.test.tsx
- src/ui/settings/persistence-toast.tsx
- src/ui/settings/index.ts
- src/ui/index.ts
- src/App.tsx
- src/PhaserGame.tsx
- src/game/scenes/Garden.ts
- tests/e2e/season1-loop.spec.ts
- playwright.config.ts
autonomous: true
requirements: [UX-02, UX-10, CORE-03, CORE-11, PIPE-07, GARD-02, GARD-04]
tags: [vertical-slice, letter, settings, save-lifecycle, offline-catchup, playwright-e2e, mvp]
must_haves:
truths:
- "Player who closes the tab and returns ≥5 minutes later sees the full-screen letter overlay; <5 minutes sees no letter (D-20)"
- "Letter renders an authored Ink skeleton with templated insertions: plants_bloomed (count), fragment_titles (string), lura_was_here (bool) — populated from offlineEvents block in V1Payload (D-17, D-18, D-19)"
- "One tap dismisses letter to live garden (D-20). Dismiss button calls bootstrapAudioContext (Pitfall 9 mitigation)."
- "Auto-harvest during offline (D-10): the silent simulate path harvests every plant that ripened; offlineEvents records {plantsBloomedCount, harvestedFragmentIds, luraBeatPending}"
- "Auto-harvest in active play does NOT fire — player chooses when to harvest active plants"
- "Save lifecycle: visibilitychange→hidden, beforeunload, AND saveOnSeasonTransition() (UX-10) all serialize the current state via wrap+CRC32+IDB-or-LocalStorage"
- "Settings menu (D-28): Export to Base64 (CORE-09), Import from Base64 (CORE-09), Restore previous snapshot (CORE-08); no audio sliders, no keyboard nav (Phase 8)"
- "Settings access: corner icon + keyboard shortcut (D-29). Persistent."
- "Persistence-result toast (D-30): one-time soft toast in voice on first save if denied; nothing if granted. State remembered via settings.persistenceToastShown."
- "Boot path: on page load, read save → migrate (still v1) → compute offlineMs via computeOfflineCatchup → if elapsedMs >= 5*60*1000, run silent catch-up loop, fill offlineEvents, open letter overlay"
- "URL flag ?devtime=fake injects FakeClock for Playwright; production-guarded (import.meta.env.PROD ignores the flag)"
- "Playwright e2e (PIPE-07): load → dismiss begin → plant → fast-forward via window.__tlgFakeClock.advance → harvest → fragment-reveal → close → journal shows fragment → reload page → fragment persists"
- "24h offline cap surfaced silently in the letter's voice (D-11); no numeric '28h' copy in any code path"
- "compost tonal beat (Plan 02-04 deferral) wires here as a small toast variant or as a tiny one-line render via the dialogue overlay — implementation choice surfaced in SUMMARY"
- "All 24 Phase-2 REQ-IDs visibly satisfied across the 5 plans of this phase"
artifacts:
- path: content/dialogue/season1/letter-from-the-garden.ink
provides: "Authored Ink letter skeleton with VAR plants_bloomed, fragment_titles, lura_was_here (D-17, D-18)"
- path: src/sim/offline/events.ts
provides: "OfflineEventBlockSchema (Zod) + aggregateOfflineEvents(prev, next) — pure"
exports: ["OfflineEventBlockSchema", "OfflineEventBlock", "aggregateOfflineEvents"]
- path: src/sim/garden/auto-harvest.ts
provides: "autoHarvestReadyPlants(state, currentTick, ctx) — silent-mode harvest branch (D-10)"
exports: ["autoHarvestReadyPlants"]
- path: src/ui/letter/Letter.tsx
provides: "Full-screen letter overlay (D-20). Loads compiled letter Ink, binds slots from offlineEvents, renders one-tap-to-dismiss"
exports: ["Letter"]
- path: src/ui/letter/letter-renderer.ts
provides: "Pure template helper: buildLetterSlots(offlineEvents, fragments) → {plants_bloomed, fragment_titles, lura_was_here}"
exports: ["buildLetterSlots"]
- path: src/ui/settings/Settings.tsx
provides: "Settings modal (D-28): Export, Import, Restore. Save-management only — Phase 8 adds audio/a11y."
exports: ["Settings"]
- path: src/ui/settings/persistence-toast.tsx
provides: "PersistenceToast (D-30) — one-time soft toast"
exports: ["PersistenceToast"]
- path: tests/e2e/season1-loop.spec.ts
provides: "Playwright PIPE-07 full-loop smoke"
key_links:
- from: src/PhaserGame.tsx
to: src/save/index.ts
via: "Boot path: openSaveDB → unwrap → migrate → drainTicks(silent=true) → if absence>=5min set offlineEvents + openLetter"
pattern: "computeOfflineCatchup\\|drainTicks"
- from: src/PhaserGame.tsx
to: src/save/lifecycle.ts
via: "registerSaveLifecycleHooks({saveSync}) — wires visibilitychange + beforeunload"
pattern: "registerSaveLifecycleHooks"
- from: src/ui/letter/Letter.tsx
to: src/content/ink-loader.ts
via: "loadInkStory('letter-from-the-garden') + bindGardenStateToInk + buildLetterSlots"
pattern: "loadInkStory\\('letter"
- from: tests/e2e/season1-loop.spec.ts
to: src/sim/scheduler/clock.ts FakeClock
via: "page.goto('/?devtime=fake') → window.__tlgFakeClock.advance(...)"
pattern: "__tlgFakeClock"
---
**Wave 2 closing plan. Depends on Plans 02-01, 02-02, 02-03, 02-04.**
This is the integration plan: it ties offline catch-up (D-10), the letter (UX-02), save lifecycle hooks (UX-10), the Settings UI (D-28..D-30), and the Playwright e2e (PIPE-07) together — proving the full Season 1 vertical slice end-to-end.
After this plan ships, Phase 2 is functionally complete: a player can launch, plant, grow, harvest, meet Lura, leave the tab for hours, and return to a letter from the garden — and the Playwright e2e proves it persists.
3 tasks. Estimated context cost ~50%. The Playwright spec is the load-bearing closing artifact.
Land the Letter-from-the-garden vertical slice + Settings UI + save lifecycle wiring + Playwright e2e (PIPE-07) — the final Phase-2 integration. After return-from-tab-close, the player sees an authored Ink letter (D-17, D-18, UX-02) with templated insertions describing what bloomed while away (auto-harvest per D-10), what Lura did (gate beat queued during absence), and what the wind brought; one tap dismisses to the live garden. Settings menu provides Export / Import / Restore (D-28) plus the in-voice persistence-result toast (D-30). Playwright e2e exercises the entire authored loop: load → begin → plant → fast-forward → harvest → reveal → close → journal-shows-fragment → reload → fragment-persists.
Purpose: Closes Phase 2. Validates that the architecture firewall holds end-to-end on real authored content + real save round-trip + real fast-forward via FakeClock injection. The PIPE-07 Playwright spec becomes the canonical proof that Phase 2 ships — `/gsd-verify-work` runs after this plan.
Output: A complete, working Phase-2-vertical-slice game that could plausibly ship as a free standalone Season 1 prologue. All 24 Phase-2 REQ-IDs structurally satisfied. `npm run ci && npx playwright test` exits 0.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@CLAUDE.md
@.planning/anti-fomo-doctrine.md
@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
@.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md
@.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md
@.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-SUMMARY.md
From src/sim/scheduler/index.ts (Plan 02-01):
```typescript
export type { Clock } from './clock';
export { wallClock, FakeClock } from './clock';
export const TICK_MS: number; // 200 (5Hz)
export const MAX_OFFLINE_MS: number; // 24 * 3600 * 1000
export function drainTicks(state: S, accumulatorMs: number, simulate, silent?: boolean): { state, remainderMs, ticksApplied };
export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): { elapsedMs, cappedMs, willRunCatchup, hitOfflineCap };
```
From src/save/index.ts (Plan 02-01 extended):
```typescript
export { wrap, unwrap } from './envelope';
export { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
export type { V1Payload, OfflineEventBlock } from './migrations'; // OfflineEventBlock TYPE declared in migrations.ts (Plan 02-01); ZOD schema in src/sim/offline/ (this plan)
export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec';
export { snapshot, listSnapshots } from './snapshots';
export { requestPersistence } from './persist';
export { openSaveDB, LocalStorageDBAdapter } from './db';
export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle';
```
From src/store/index.ts (Plan 02-01):
```typescript
session.letterOverlayOpen: boolean;
session.pendingLetterEventBlock: unknown | null;
openLetter(block: unknown): void;
dismissLetter(): void;
session.persistenceToastShown: boolean;
setPersistenceToastShown(v: boolean): void;
```
From src/sim/garden/commands.ts (Plan 02-03 + 02-04):
```typescript
export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState;
// ^^ this plan adds an `autoHarvest` branch to simulateOneTick when called with `silent: true`
```
V1Payload offlineEvents shape (Plan 02-01 declared inline):
```typescript
export interface OfflineEventBlock {
plantsBloomedCount: Record;
harvestedFragmentIds: string[];
luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
}
```
From src/ui/dialogue (Plan 02-04):
```typescript
export const InkRenderer: React.FC<{ runtime: InkRuntime; onComplete?: () => void }>;
export function createInkRuntime(story: Story): InkRuntime;
```
From src/content/ink-loader.ts (Plan 02-04):
```typescript
export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise;
// ^^ this plan extends to also accept 'letter-from-the-garden'
export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void;
```
From src/PhaserGame.tsx (Plan 02-02):
The boot path currently sets `unlockedPlantTypes: ['rosemary']` if empty, mounts Phaser, listens for scene-ready. THIS PLAN replaces that bootstrap with a real save-load path: read save (if present) → migrate → set initial store state from migrated V1Payload → compute offline → run silent catch-up → maybe open letter.
From content/dialogue/season1/ (Plan 02-04 ships 4 .ink files):
- lura-arrival.ink, lura-mid.ink, lura-farewell.ink, compost-acknowledgements.ink. THIS PLAN adds: letter-from-the-garden.ink.
playwright.config.ts (already shipped Phase 1):
```typescript
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 30000 },
```
Task 1: sim/offline + auto-harvest + extended ink-loader for letter + letter-from-the-garden.ink authoring
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 6 lines 802-840 letter Ink template, Pitfall 4 line 1057 snake_case, Pitfall 9 line 1110 letter dismiss audio)
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group F lines 350-376 zod schema)
- .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-10 auto-harvest, D-11 silent 24h cap, D-17/D-18/D-19/D-20 letter)
- src/save/migrations.ts (OfflineEventBlock interface declared in Plan 02-01)
- src/sim/garden/commands.ts (Plans 02-03 + 02-04 — harvest + Lura integration)
- src/content/ink-loader.ts (Plan 02-04 — extend the union to accept 'letter-from-the-garden')
- CLAUDE.md (Tone — letter must read in voice, not stat dump)
src/sim/offline/events.ts,
src/sim/offline/events.test.ts,
src/sim/offline/index.ts,
src/sim/garden/auto-harvest.ts,
src/sim/garden/auto-harvest.test.ts,
src/sim/garden/commands.ts,
src/sim/garden/index.ts,
src/sim/index.ts,
content/dialogue/season1/letter-from-the-garden.ink,
src/content/ink-loader.ts,
src/ui/letter/letter-renderer.ts,
src/ui/letter/letter-renderer.test.ts
**Step 1 — `src/sim/offline/events.ts`** — Zod schema + aggregator:
```typescript
import { z } from 'zod';
/**
* OfflineEventBlock — captures what happened while the player was away.
* Per CONTEXT D-19. Phase 2 ships the minimum slot vocabulary;
* Phase 4+ may add more if playtest demands.
*
* Structurally compatible with the OfflineEventBlock interface declared
* in src/save/migrations.ts (Plan 02-01); the Zod schema here is the
* runtime validator.
*/
export const OfflineEventBlockSchema = z.object({
plantsBloomedCount: z.record(z.string(), z.number().int().nonnegative()),
harvestedFragmentIds: z.array(z.string().regex(/^season\d+\.[a-z0-9._-]+$/)),
luraBeatPending: z.enum(['arrival', 'mid', 'farewell']).nullable(),
});
export type OfflineEventBlock = z.infer;
export const EMPTY_OFFLINE_EVENTS: OfflineEventBlock = Object.freeze({
plantsBloomedCount: {},
harvestedFragmentIds: [],
luraBeatPending: null,
});
/**
* Pure aggregator — combines a previous OfflineEventBlock with a new
* (plantTypeId, fragmentId, luraBeatPending?) tuple from a single
* silent-mode auto-harvest event.
*/
export function aggregateOfflineEvent(
prev: OfflineEventBlock,
plantTypeId: string,
fragmentId: string,
luraBeatPending: OfflineEventBlock['luraBeatPending'],
): OfflineEventBlock {
const counts = { ...prev.plantsBloomedCount };
counts[plantTypeId] = (counts[plantTypeId] ?? 0) + 1;
return {
plantsBloomedCount: counts,
harvestedFragmentIds: [...prev.harvestedFragmentIds, fragmentId],
luraBeatPending: luraBeatPending ?? prev.luraBeatPending,
};
}
```
**Step 2 — `src/sim/offline/events.test.ts`** — Vitest:
- `OfflineEventBlockSchema.parse(EMPTY_OFFLINE_EVENTS)` succeeds.
- Schema rejects: missing field, wrong-type field, fragment id with bad regex.
- `aggregateOfflineEvent(EMPTY, 'rosemary', 'season1.soil.first-bloom', null)` returns block with plantsBloomedCount.rosemary=1.
- Two consecutive aggregates increment counts correctly.
- luraBeatPending overwrites only when newer is non-null AND prev was null.
**Step 3 — `src/sim/offline/index.ts`:**
```typescript
export { OfflineEventBlockSchema, EMPTY_OFFLINE_EVENTS, aggregateOfflineEvent } from './events';
export type { OfflineEventBlock } from './events';
```
Add `export * from './offline'` to `src/sim/index.ts`.
**Step 4 — `src/sim/garden/auto-harvest.ts`** — silent-mode harvest branch (D-10):
```typescript
import type { SimState } from '../state';
import type { Tile } from './types';
import { PLANT_TYPES } from './plants';
import { advanceGrowth } from './growth';
import { harvest } from './commands';
import type { SimContext } from './commands';
import { aggregateOfflineEvent } from '../offline/events';
import { EMPTY_OFFLINE_EVENTS } from '../offline/events';
/**
* D-10 — auto-harvest during offline. While the player is away, plants
* that ripen are auto-harvested, populating the offlineEvents block
* that the *letter* will narrate.
*
* Pure. Called inside drainTicks's silent-mode simulate function.
*/
export function autoHarvestReadyPlants(
state: SimState,
currentTick: number,
ctx: SimContext,
): SimState {
let next = state;
const tiles = state.garden.tiles as Tile[];
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i];
if (!tile?.plant) continue;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) continue;
const stage = advanceGrowth(tile.plant, type, currentTick);
if (stage !== 'ready') continue;
// Snapshot fields we'll need to populate offlineEvents
const harvestedBefore = next.harvestedFragmentIds.length;
const plantTypeId = tile.plant.plantTypeId;
// Reuse the standard harvest pipeline (selector + plant-unlock + Lura gate)
next = harvest(next, i, currentTick, ctx);
// If a fragment was actually selected, append to offline events
if (next.harvestedFragmentIds.length > harvestedBefore) {
const newId = next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1];
const luraPending = next.luraBeatProgress.pending;
const prevEvents = (next.offlineEvents as ReturnType | null) ?? EMPTY_OFFLINE_EVENTS;
next = {
...next,
offlineEvents: aggregateOfflineEvent(prevEvents, plantTypeId, newId, luraPending),
};
}
}
return next;
}
```
**Step 5 — `src/sim/garden/auto-harvest.test.ts`** — Vitest:
- A 4×4 garden with 2 ready rosemary plants → autoHarvestReadyPlants returns state with both tiles cleared, offlineEvents.plantsBloomedCount.rosemary=2, harvestedFragmentIds.length grew by 2.
- An immature plant is NOT auto-harvested.
- An empty tile is a no-op.
- After auto-harvest crosses the 1-fragment threshold, offlineEvents.luraBeatPending === 'arrival'.
**Step 6 — Update `simulateOneTick`** in `src/sim/garden/commands.ts` to call `autoHarvestReadyPlants` when called in silent mode:
In Plan 02-03, `simulateOneTick` accepted `(state, currentTick, commands, ctx)`. Add a 5th argument `silent: boolean` (or pass via ctx). Simpler: add `silent` to ctx:
```typescript
export interface SimContext {
fragments: readonly Fragment[];
currentSeason: number;
silent?: boolean; // when true, simulateOneTick auto-harvests ready plants (D-10)
}
export function simulateOneTick(state, currentTick, commands, ctx): SimState {
let next = state;
for (const cmd of commands) { /* ... existing cases ... */ }
if (ctx.silent) {
next = autoHarvestReadyPlants(next, currentTick, ctx);
}
return { ...next, lastTickAt: currentTick };
}
```
Update `src/sim/garden/index.ts`:
```typescript
export { autoHarvestReadyPlants } from './auto-harvest';
```
**Step 7 — Author `content/dialogue/season1/letter-from-the-garden.ink`** (RESEARCH Pattern 6):
```ink
// Letter from the garden — UX-02 + D-17 + D-18.
// Composed from authored skeleton + templated insertions per CONTEXT D-17.
// Slots populated at runtime from sim/offline/events.ts via the variable
// map in src/content/ink-loader.ts.
//
// Per Pitfall 4: variable names are snake_case AND case-sensitive.
// Per CONTEXT D-11: 24h offline cap is silent in voice — no numeric "28h" copy.
//
// The skeleton MUST read like authored fiction (CLAUDE.md Tone). The
// slots fill in the specifics.
VAR plants_bloomed = 0
VAR fragment_titles = ""
VAR lura_was_here = false
VAR fragment_count = 0
VAR last_plant_type = ""
== letter ==
The garden held its breath while you were gone.
{ plants_bloomed > 1:
{plants_bloomed} blooms came and went, each leaving the soil a little quieter than they found it.
- else:
{ plants_bloomed == 1:
One bloom came and went. The space it left feels generous, somehow.
- else:
Nothing bloomed. The wind carried something else, and the garden held that, too.
}
}
{ fragment_titles != "":
Among what stayed: {fragment_titles}.
}
{ lura_was_here:
Lura came by once. She did not knock. She left a folded leaf on the gate post — you'll find it when you next walk past.
}
The light is the same as when you left. The garden is older.
-> END
```
**Step 8 — Update `src/content/ink-loader.ts`** to support the letter:
Extend the `loadInkStory` union AND `INK_VARIABLE_MAP`:
```typescript
const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {...});
const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {...});
const letterStory = import.meta.glob('/src/content/compiled-ink/season1/letter-from-the-garden.ink.json', {
query: '?raw', import: 'default',
});
export const INK_VARIABLE_MAP = {
fragment_count: (s) => s.harvestedFragmentIds.length,
last_plant_type: (s) => { /* ... unchanged ... */ },
// NEW for letter:
plants_bloomed: (s) => {
const counts = (s.pendingLetterEventBlock as { plantsBloomedCount?: Record } | null)?.plantsBloomedCount ?? {};
return Object.values(counts).reduce((a, b) => a + b, 0);
},
fragment_titles: (s) => {
const ids = (s.pendingLetterEventBlock as { harvestedFragmentIds?: string[] } | null)?.harvestedFragmentIds ?? [];
if (ids.length === 0) return '';
// Convert IDs to a comma-joined human-friendly list. For Phase 2, slugify the ID's last segment.
return ids.map((id) => id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' ')).join(', ');
},
lura_was_here: (s) => Boolean((s.pendingLetterEventBlock as { luraBeatPending?: string | null } | null)?.luraBeatPending),
} as const;
export async function loadInkStory(
name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements' | 'letter-from-the-garden',
): Promise {
let path: string; let loader;
if (name === 'compost-acknowledgements') {
path = `/src/content/compiled-ink/season1/${name}.ink.json`;
loader = compostStory[path];
} else if (name === 'letter-from-the-garden') {
path = `/src/content/compiled-ink/season1/${name}.ink.json`;
loader = letterStory[path];
} else {
path = `/src/content/compiled-ink/season1/${name}.ink.json`;
loader = luraStories[path];
}
if (!loader) throw new Error(`[ink-loader] No compiled story at ${path}.`);
const json = (await loader()) as string;
return new Story(json);
}
```
**Step 9 — `src/ui/letter/letter-renderer.ts`** — pure helper for slot building (separate from the React component for testability):
```typescript
import type { OfflineEventBlock } from '../../sim/offline';
import type { Fragment } from '../../content';
/**
* Build the variable slot values for letter-from-the-garden.ink from
* an OfflineEventBlock + the fragment pool (for human-readable titles).
*
* Pure. Used by Letter.tsx via INK_VARIABLE_MAP at bind time.
*/
export interface LetterSlots {
plants_bloomed: number;
fragment_titles: string;
lura_was_here: boolean;
}
export function buildLetterSlots(
events: OfflineEventBlock | null,
allFragments: readonly Fragment[],
): LetterSlots {
if (!events) return { plants_bloomed: 0, fragment_titles: '', lura_was_here: false };
const total = Object.values(events.plantsBloomedCount).reduce((a, b) => a + b, 0);
// For human-readable titles: use the fragment id's last segment, slugified to spaces
const titles = events.harvestedFragmentIds
.map((id) => {
const f = allFragments.find((x) => x.id === id);
// Prefer the fragment's first sentence (up to 60 chars) for tonal weight; fall back to id slug
if (f) {
const firstLine = f.body.split(/[.!?]/)[0]?.trim() ?? '';
if (firstLine.length > 0 && firstLine.length <= 60) return firstLine.toLowerCase();
}
return id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' ');
})
.filter((t) => t.length > 0);
return {
plants_bloomed: total,
fragment_titles: titles.join('; '),
lura_was_here: events.luraBeatPending !== null,
};
}
```
**Step 10 — `src/ui/letter/letter-renderer.test.ts`** — Vitest:
- Empty events → all zeros / empty / false.
- Single rosemary auto-harvest → plants_bloomed=1, fragment_titles uses the fragment's first-sentence slug.
- luraBeatPending='arrival' → lura_was_here=true.
- 24h cap edge case: 50 plants bloomed → plants_bloomed=50 (no truncation; the Ink template handles "many" copy).
**Commit:** `feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer`. Run `npm run lint && npm run compile:ink && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci` before committing.
- `grep -q "OfflineEventBlockSchema" src/sim/offline/events.ts`
- `grep -q "aggregateOfflineEvent" src/sim/offline/events.ts`
- `grep -q "autoHarvestReadyPlants" src/sim/garden/auto-harvest.ts`
- `grep -q "ctx.silent" src/sim/garden/commands.ts` (silent mode triggers auto-harvest)
- `test -f content/dialogue/season1/letter-from-the-garden.ink`
- `grep -q "VAR plants_bloomed" content/dialogue/season1/letter-from-the-garden.ink`
- `grep -q "VAR lura_was_here" content/dialogue/season1/letter-from-the-garden.ink`
- `grep -q "letter-from-the-garden" src/content/ink-loader.ts` (loadInkStory accepts the union case)
- `grep -q "buildLetterSlots" src/ui/letter/letter-renderer.ts`
- `grep -L "Date.now\\|setInterval" src/sim/offline/events.ts src/sim/garden/auto-harvest.ts` (sim purity)
- `npm run compile:ink` produces `src/content/compiled-ink/season1/letter-from-the-garden.ink.json`
- `npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts` exits 0
- `npm run ci` exits 0
npm run compile:ink && npm run lint && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci
sim/offline ships Zod schema + aggregator. autoHarvestReadyPlants extends silent-mode simulate. letter-from-the-garden.ink authored in voice. ink-loader supports the letter. letter-renderer builds slots purely. All sim modules sim-pure. `npm run ci` green.
Task 2: Letter overlay + Settings + persistence-toast UIs + boot-path save lifecycle wiring + URL-flag clock injection
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Sim-Clock Injection lines 1377-1387, Open Question 5 lines 1245-1248, AudioContext bootstrap Pattern 9, Pitfall 9 line 1110 letter dismiss audio)
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I React mounting, Group M PhaserGame.tsx hook addition lines 663-690)
- .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-20 letter UX, D-28 Settings scope, D-29 access pattern, D-30 toast)
- src/PhaserGame.tsx (Plan 02-02 — wire boot path here)
- src/save/index.ts (Plan 02-01 — barrel of all save APIs)
- src/store/session-slice.ts (Plan 02-01 — letterOverlayOpen, persistenceToastShown)
- src/ui/journal/Journal.tsx (analog full-screen modal pattern)
- src/ui/dialogue/LuraDialogue.tsx (analog Ink-driven overlay)
src/ui/letter/Letter.tsx,
src/ui/letter/Letter.test.tsx,
src/ui/letter/index.ts,
src/ui/settings/Settings.tsx,
src/ui/settings/Settings.test.tsx,
src/ui/settings/persistence-toast.tsx,
src/ui/settings/index.ts,
src/ui/index.ts,
src/save/migrations.ts,
src/PhaserGame.tsx,
src/game/scenes/Garden.ts,
src/App.tsx
**Step 1 — `src/ui/letter/Letter.tsx`** (D-20 + Pitfall 9):
```typescript
import { useEffect, useState } from 'react';
import { useAppStore } from '../../store';
import { loadInkStory, fragments as allFragments } from '../../content';
import { createInkRuntime, InkRenderer, type InkRuntime } from '../dialogue';
import { bootstrapAudioContext } from '../begin';
import { buildLetterSlots } from './letter-renderer';
import type { OfflineEventBlock } from '../../sim/offline';
/**
* UX-02 + D-20 — Letter from the garden. Full-screen overlay; one tap
* dismisses to the live garden. Triggered when absence ≥ 5 minutes
* (the threshold check lives in the boot path in src/PhaserGame.tsx).
*
* Per Pitfall 9: dismiss must call bootstrapAudioContext too — a returning
* player who lands directly in the letter would otherwise have no audio
* gesture before reaching the live garden.
*/
export function Letter(): JSX.Element | null {
const open = useAppStore((s) => s.letterOverlayOpen);
const block = useAppStore((s) => s.pendingLetterEventBlock) as OfflineEventBlock | null;
const dismissLetter = useAppStore((s) => s.dismissLetter);
const [runtime, setRuntime] = useState(null);
useEffect(() => {
if (!open) {
setRuntime(null);
return;
}
let cancelled = false;
(async () => {
try {
const story = await loadInkStory('letter-from-the-garden');
if (cancelled) return;
const slots = buildLetterSlots(block, allFragments);
try { story.variablesState['plants_bloomed'] = slots.plants_bloomed; } catch {}
try { story.variablesState['fragment_titles'] = slots.fragment_titles; } catch {}
try { story.variablesState['lura_was_here'] = slots.lura_was_here; } catch {}
story.ChoosePathString('letter');
setRuntime(createInkRuntime(story));
} catch (err) {
console.error('[Letter] failed to load', err);
dismissLetter();
}
})();
return () => { cancelled = true; };
}, [open, block, dismissLetter]);
if (!open) return null;
const onDismiss = () => {
void bootstrapAudioContext(); // Pitfall 9: returning player audio gesture
dismissLetter();
};
return (
);
}
```
**Step 2 — `src/ui/letter/Letter.test.tsx`** — Vitest:
- With `letterOverlayOpen: false`, returns null.
- With `open: true` and `pendingLetterEventBlock: null`, mounts the dialog (loading state).
- Dismiss button click dispatches `dismissLetter()` AND calls `bootstrapAudioContext` (spy).
- Click on backdrop dismisses; click on article does NOT.
**Step 3 — `src/ui/letter/index.ts`:**
```typescript
export { Letter } from './Letter';
export { buildLetterSlots } from './letter-renderer';
export type { LetterSlots } from './letter-renderer';
```
**Step 4 — `src/ui/settings/Settings.tsx`** (D-28 save-management only):
```typescript
import { useState } from 'react';
import { useAppStore } from '../../store';
import { exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB, wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, type V1Payload } from '../../save';
import { uiStrings } from '../../content';
/**
* D-28 — Phase 2 Settings UI. Save-management surfaces only.
* Audio sliders + keyboard nav + a11y polish ship in Phase 8.
*/
export function Settings({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null {
const strings = uiStrings[1]?.settings;
const [base64Buf, setBase64Buf] = useState('');
const [statusLine, setStatusLine] = useState(null);
if (!open || !strings) return null;
const onExport = async () => {
try {
// Build a fresh save envelope from current store state.
// (Plan 02-05 wires the same payload-build path in src/PhaserGame.tsx for save lifecycle hooks.)
const state = useAppStore.getState();
const payload: V1Payload = buildPayloadFromStore(state);
const env = wrap(payload, CURRENT_SCHEMA_VERSION);
const b64 = await exportToBase64(env);
navigator.clipboard?.writeText(b64).catch(() => {});
setBase64Buf(b64);
setStatusLine('Saved to clipboard.');
} catch (e) {
setStatusLine('Could not save.');
}
};
const onImport = async () => {
try {
// BLOCKER 2 fix — pipeline order: importFromBase64 → unwrap (CRC verify)
// → migrate. unwrap() returns the payload directly (NOT { payload }), so
// the previous v1.payload read crashed at runtime; migrate() was also
// called against env.payload before checksum verification. Correct order:
const env = await importFromBase64(base64Buf);
const raw = unwrap(env); // verifies CRC
const { payload } = migrate(raw, env.schemaVersion); // upgrades schema
hydrateStoreFromPayload(payload as V1Payload);
setStatusLine('Restored.');
} catch (e) {
setStatusLine('That doesn\'t look like one of yours.');
}
};
const onRestoreSnapshot = async () => {
try {
const db = await openSaveDB();
const snaps = await listSnapshots(db);
if (snaps.length === 0) {
setStatusLine('Nothing earlier to find.');
return;
}
// Restore the most-recent snapshot (Phase 2 ships single-action restore;
// Phase 8 may add a list selector).
const last = snaps[snaps.length - 1];
const payload = unwrap(last.envelope);
hydrateStoreFromPayload(payload as V1Payload);
setStatusLine('Earlier garden restored.');
} catch (e) {
setStatusLine('Nothing earlier could be reached.');
}
};
return (
{strings.title}
);
}
const btnStyle: React.CSSProperties = {
display: 'block', margin: '0.5rem 0', padding: '0.5rem 1rem',
background: 'transparent', color: '#e8e0d0',
border: '1px solid #4d4d52', cursor: 'pointer',
fontFamily: 'serif', textAlign: 'left', width: '100%',
};
// Helpers — these live here for now; can be extracted to src/save/ if reused.
// BLOCKER 3 invariants:
// - lastTickAt is wall-clock ms (set here at export time via Date.now())
// - tickCount is the sim-internal monotonic counter (read from the store;
// simAdapter.applyTickCount writes it into the store every Garden.update
// so Settings.tsx can read it without coupling to the active scene)
function buildPayloadFromStore(s: ReturnType): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt: Date.now(),
tickCount: s.tickCount ?? 0,
unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress,
offlineEvents: null,
settings: {
musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8,
persistenceToastShown: s.persistenceToastShown,
},
};
}
function hydrateStoreFromPayload(payload: V1Payload): void {
const state = useAppStore.getState();
state.applyTilesAndUnlocks(payload.garden.tiles, payload.unlockedPlantTypes);
state.setHarvested(payload.harvestedFragmentIds);
state.setLuraBeatProgress(payload.luraBeatProgress);
state.setPersistenceToastShown(payload.settings.persistenceToastShown);
// BLOCKER 3 — restore the sim's tick counter so a returning player resumes
// where they left off rather than restarting at tick 0.
state.setTickCount?.(payload.tickCount ?? 0);
}
```
(Phase 2 minimum-viable Settings — clipboard read/paste UX is acceptable; Phase 8 polishes.)
**Step 5 — `src/ui/settings/Settings.test.tsx`** — Vitest:
- With `open: false`, returns null.
- With `open: true`, mounts the dialog with all four buttons.
- Close button calls `onClose` callback once.
- (Skip the round-trip via real save layer — covered by Phase 1's existing round-trip test + Playwright e2e here.)
**Step 6 — `src/ui/settings/persistence-toast.tsx`** (D-30):
```typescript
import { useEffect, useState } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
/**
* D-30 — one-time soft toast in voice on first save if persistence was
* denied; nothing if granted. State remembered via settings.persistenceToastShown.
*
* Triggered by src/PhaserGame.tsx after registerSaveLifecycleHooks +
* requestPersistence resolves (Plan 02-05 boot path).
*/
export function PersistenceToast({ show }: { show: boolean }): JSX.Element | null {
const [visible, setVisible] = useState(show);
const setShown = useAppStore((s) => s.setPersistenceToastShown);
const strings = uiStrings[1]?.settings;
useEffect(() => {
if (!show) return;
const t = setTimeout(() => {
setVisible(false);
setShown(true);
}, 6500);
return () => clearTimeout(t);
}, [show, setShown]);
if (!visible || !strings) return null;
return (
{strings.persistence_denied_toast}
);
}
```
(A small `SettingsIcon` sibling component opens Settings — minimum-viable: a corner button. The executor adds this inline in App.tsx for Phase 2; Phase 8 styles it properly.)
**Step 7 — `src/ui/settings/index.ts`:**
```typescript
export { Settings } from './Settings';
export { PersistenceToast } from './persistence-toast';
```
Update `src/ui/index.ts`:
```typescript
export * from './begin';
export * from './garden';
export * from './journal';
export * from './dialogue';
export * from './letter';
export * from './settings';
```
**Step 8 — Update `src/save/migrations.ts`** to import the runtime Zod schema for `OfflineEventBlock` validation. Actually, leave the type alone (declared inline in Plan 02-01); the runtime Zod check happens in `src/sim/offline/events.ts`. No edits needed here unless the executor finds a structural mismatch.
**Step 9 — Wire boot path in `src/PhaserGame.tsx`** — replaces Plan 02-02's placeholder:
```typescript
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
import StartGame from './game/main.ts';
import type * as Phaser from 'phaser';
import { eventBus } from './game/event-bus';
import { appStore } from './store';
import { installFirstInteractionGestureHandler } from './ui/begin';
import {
openSaveDB, requestPersistence, wrap, unwrap, migrate,
CURRENT_SCHEMA_VERSION, registerSaveLifecycleHooks,
type V1Payload, type SavedRecord,
} from './save';
import {
wallClock, FakeClock, drainTicks, computeOfflineCatchup, TICK_MS,
} from './sim/scheduler';
import { simulateOneTick } from './sim/garden';
import { fragments as allFragments } from './content';
const ABSENCE_LETTER_THRESHOLD_MS = 5 * 60 * 1000;
export interface IRefPhaserGame {
game: Phaser.Game | null;
scene: Phaser.Scene | null;
}
interface IProps {
currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
}
export const PhaserGame = forwardRef(function PhaserGame(props, ref) {
const game = useRef(null);
const sceneRef = useRef(null);
// W5 — hold the lifecycle handle so the OUTER useLayoutEffect cleanup can
// call detach(). The async IIFE that registers the hooks cannot return its
// own cleanup to the effect.
const lifecycleRef = useRef<{ detach: () => void } | null>(null);
const [persistenceToastShow, setPersistenceToastShow] = useState(false);
// Select clock: dev-time URL flag + non-prod build → FakeClock; otherwise wallClock
useLayoutEffect(() => {
const isProd = (typeof import.meta.env !== 'undefined' && (import.meta.env as { PROD?: boolean }).PROD === true);
const params = new URLSearchParams(window.location.search);
const devtime = params.get('devtime');
const useFake = !isProd && devtime === 'fake';
const fake = new FakeClock(Date.now());
(window as unknown as { __tlgClock?: unknown; __tlgFakeClock?: FakeClock }).__tlgClock = useFake ? fake : wallClock;
if (useFake) (window as unknown as { __tlgFakeClock?: FakeClock }).__tlgFakeClock = fake;
}, []);
// Boot path: load save → migrate → catch up offline → maybe open letter
useLayoutEffect(() => {
if (game.current !== null) return;
let cancelled = false;
(async () => {
const clock = ((window as unknown as { __tlgClock?: { now: () => number } }).__tlgClock) ?? wallClock;
const nowMs = clock.now();
try {
const db = await openSaveDB();
const record: SavedRecord | undefined = await db.get('saves', 'main');
if (cancelled) return;
if (record) {
// Returning player path
// BLOCKER 1 fix — boot path must run unwrap (CRC verify) THEN migrate.
// Casting unwrap(record.envelope) directly to V1Payload silently
// accepts any future-shape payload as the current shape; migrate()
// is the only place that walks the schema version chain.
const env = record.envelope;
const raw = unwrap(env);
const { payload: migratedPayload } = migrate(raw, env.schemaVersion);
const payload = migratedPayload as V1Payload;
appStore.getState().dismissBeginGate(); // D-22: skip Begin
hydrateStoreFromPayload(payload);
// Offline catch-up
const off = computeOfflineCatchup(payload.lastTickAt, nowMs);
if (off.willRunCatchup) {
// Build a SimContext for silent mode and drain ticks
const ctx = { fragments: allFragments, currentSeason: 1, silent: true } as const;
const baseSim = payload as unknown as Parameters[0];
const result = drainTicks(baseSim, off.cappedMs, (s, _dt, silent) => {
return simulateOneTick(s as never, (s as { lastTickAt: number }).lastTickAt + 1, [], { ...ctx, silent });
}, true);
const finalState = result.state as unknown as V1Payload;
hydrateStoreFromPayload(finalState);
if (off.cappedMs >= ABSENCE_LETTER_THRESHOLD_MS) {
appStore.getState().openLetter(finalState.offlineEvents);
}
}
} else {
// First-run path — initialize unlocks
if (appStore.getState().unlockedPlantTypes.length === 0) {
appStore.setState({ unlockedPlantTypes: ['rosemary'] });
}
}
// Persistence request (CORE-05) — surface toast iff denied AND not previously shown
const persistResult = await requestPersistence();
if (
persistResult.apiAvailable && !persistResult.granted &&
!appStore.getState().persistenceToastShown
) {
setPersistenceToastShow(true);
}
} catch (err) {
console.error('[boot] save load failed; starting fresh', err);
}
// Start Phaser AFTER state hydration so the Garden scene reads correct initial tiles
game.current = StartGame('game-container');
if (typeof ref === 'function') ref({ game: game.current, scene: null });
else if (ref) ref.current = { game: game.current, scene: null };
// Register save lifecycle hooks (UX-10).
// W5 fix — lifecycle.detach() must reach the useLayoutEffect cleanup,
// but the registration happens inside an async IIFE so the inner
// `return () => lifecycle.detach()` is the IIFE's return value, not
// the effect's cleanup. Hold the handle in a useRef declared at the
// top of the component, so the OUTER cleanup (already returned below)
// can call detach on the ref's current value.
lifecycleRef.current = registerSaveLifecycleHooks({
saveSync: () => {
try {
const state = appStore.getState();
const payload: V1Payload = buildPayloadFromStore(state, clock.now());
const env = wrap(payload, CURRENT_SCHEMA_VERSION);
// Synchronous LocalStorage path (Pitfall 7 — no await on beforeunload)
try { localStorage.setItem('tlg.saves.main', JSON.stringify(env)); } catch {}
// Best-effort IDB
void db.put('saves', { id: 'main', envelope: env });
} catch (e) {
console.warn('[saveSync] failed', e);
}
},
});
})();
return () => {
cancelled = true;
lifecycleRef.current?.detach();
lifecycleRef.current = null;
};
}, [ref]);
useEffect(() => {
const onSceneReady = (scene: Phaser.Scene) => {
sceneRef.current = scene;
props.currentActiveScene?.(scene);
};
eventBus.on('scene-ready', onSceneReady);
installFirstInteractionGestureHandler();
return () => { eventBus.off('scene-ready', onSceneReady); };
}, [props]);
useImperativeHandle(ref, () => ({
game: game.current,
scene: sceneRef.current,
}));
return (
<>
{persistenceToastShow && }
>
);
});
// Lazily import the toast to avoid circular deps
function PersistenceToastWrapper(): JSX.Element | null {
// Late-import to avoid a circular import via src/ui/index.ts
const ToastModule = require('./ui/settings/persistence-toast') as { PersistenceToast: React.FC<{ show: boolean }> };
return ;
}
// Helpers (mirror Settings.tsx — deduplicate by extracting if reused).
// BLOCKER 3 invariant: `lastTickAt` is WALL-CLOCK MILLISECONDS (the value of
// clock.now() at save time). The sim never writes lastTickAt — only saveSync
// in PhaserGame.tsx and the Settings export path do. computeOfflineCatchup
// reads the same wall-clock convention.
function buildPayloadFromStore(s: ReturnType, lastTickAt: number): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt, // wall-clock ms, owned by saveSync
tickCount: s.tickCount ?? 0, // BLOCKER 3 — sim-internal counter from store
unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress,
offlineEvents: null,
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: s.persistenceToastShown },
};
}
function hydrateStoreFromPayload(payload: V1Payload): void {
const state = appStore.getState();
state.applyTilesAndUnlocks(payload.garden.tiles ?? new Array(16).fill(null), payload.unlockedPlantTypes ?? []);
state.setHarvested(payload.harvestedFragmentIds ?? []);
state.setLuraBeatProgress(payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null });
state.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false);
// BLOCKER 3 — restore tickCount so the sim's STRY-10 narrative gating
// resumes from the correct counter on return.
state.setTickCount?.(payload.tickCount ?? 0);
}
```
(NOTE: The `require` inside `PersistenceToastWrapper` will fail in ESM — replace with a top-level import. Reorganize the file so PersistenceToast imports cleanly. The above is a sketch; the executor finalizes the import order to avoid circular deps. Acceptable approach: move `PersistenceToast` rendering up to App.tsx and pass the `show` boolean via the store — see Step 11 below.)
**Step 10 — Update `src/game/scenes/Garden.ts`** to read from the externally-provided clock (no local fallback to wallClock anymore — the boot path injects via `window.__tlgClock`):
```typescript
import { wallClock, type Clock } from '../../sim/scheduler';
// In Garden:
private get clock(): Clock {
return ((window as unknown as { __tlgClock?: Clock }).__tlgClock) ?? wallClock;
}
```
(Plan 02-02 already had a similar read; this plan formalizes via getter. No semantic change.)
**Step 11 — Update `src/App.tsx`** to mount Letter, Settings, PersistenceToast, and a SettingsIcon:
```typescript
import { useState } from 'react';
import { useRef } from 'react';
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
import { BeginScreen } from './ui/begin';
import { SeedPicker } from './ui/garden';
import { JournalIcon, FragmentRevealModal } from './ui/journal';
import { LuraDialogue } from './ui/dialogue';
import { Letter } from './ui/letter';
import { Settings, PersistenceToast } from './ui/settings';
import { useAppStore } from './store';
function App() {
const phaserRef = useRef(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const persistenceToastShown = useAppStore((s) => s.persistenceToastShown);
// W2 fix — D-29 keyboard shortcuts. Comma toggles Settings (a tasteful nod
// to settings being a subordinate concern; easy to reach; no browser conflict).
// 'j' toggles the Memory Journal (Plan 02-03 wires the open/close state via
// the JournalIcon's local useState — for the hotkey, dispatch a custom event
// that JournalIcon listens for, OR lift the journal-open state into the
// store. Minimum-viable: expose a window-scoped toggler from JournalIcon
// and call it here. Pick whichever feels least invasive when implementing;
// record the choice in SUMMARY.md.)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Don't trigger when the user is typing into an input/textarea
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (e.key === ',') {
e.preventDefault();
setSettingsOpen((o) => !o);
} else if (e.key === 'j' || e.key === 'J') {
e.preventDefault();
// Dispatch a window event the JournalIcon listens for (kept decoupled
// so JournalIcon owns its open/close state). JournalIcon adds a
// matching addEventListener('tlg:toggle-journal', ...) handler.
window.dispatchEvent(new CustomEvent('tlg:toggle-journal'));
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return (
setSettingsOpen(false)} />
);
}
export default App;
```
(The PersistenceToast wiring above is sketch. To resolve cleanly: the toast is shown when boot determines persistence was denied. Since the boot path lives in PhaserGame.tsx, the cleanest decoupling is: PhaserGame writes a transient flag into the store (e.g., `session.showPersistenceToast: boolean`), and App reads from the store. Add this slot to `src/store/session-slice.ts` as part of THIS task or surface in SUMMARY.md. Minimum-viable acceptable: PhaserGame returns a `` directly inline in its return JSX — see Step 9 sketch.)
**Commit:** `feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection`. Run `npm run lint && npm run ci` before committing.
- `grep -q "Letter from the garden" content/dialogue/season1/letter-from-the-garden.ink` (header comment)
- `grep -q "" src/App.tsx` and `grep -q "npm run lint && npx vitest run src/ui/letter/ src/ui/settings/ && npm run ci
Letter overlay loads compiled letter Ink, binds slots, dismisses to live garden + bootstraps audio. Settings ships save-management surface (Export/Import/Restore). Persistence-result toast wired. PhaserGame.tsx boot path: load save → migrate → silent catch-up → maybe open letter. URL ?devtime=fake injects FakeClock; production-guarded. App.tsx mounts all overlays.
Task 3: Playwright e2e (PIPE-07) — full Phase-2 loop smoke test
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Sim-Clock Injection lines 1377-1387, Validation Architecture e2e row PIPE-07)
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group N lines 695-727)
- playwright.config.ts (Phase 1 — already configured)
- src/PhaserGame.tsx (FakeClock injection — confirms `window.__tlgFakeClock.advance` works)
- src/ui/begin/BeginScreen.tsx (data-testids and button text for click selectors)
- src/ui/garden/SeedPicker.tsx (`data-testid="seed-picker"`)
- src/ui/journal/Journal.tsx (`data-fragment-id` attribute on each fragment article)
- src/game/scenes/Garden.ts (tile pointerdown — Playwright cannot easily click into a Phaser canvas; alternative: dispatch synthetic pointer events on the canvas element OR enqueueCommand directly via `appStore` exposed on window)
tests/e2e/season1-loop.spec.ts,
playwright.config.ts,
src/PhaserGame.tsx,
package.json
**Step 1 — Add a small test-only window hook to `src/PhaserGame.tsx`** to enable Playwright to dispatch sim commands without needing pixel-precise canvas clicks:
```typescript
// Inside the dev-clock useLayoutEffect block:
if (!isProd && devtime === 'fake') {
// Expose the store + sim helpers for Playwright tests
(window as unknown as Record).__tlgStore = appStore;
}
```
(This exposes `window.__tlgStore.getState().enqueueCommand({...})` to Playwright, sidestepping the difficulty of clicking into a Phaser canvas.)
**Step 2 — `tests/e2e/season1-loop.spec.ts`** — full PIPE-07 smoke:
```typescript
import { test, expect } from '@playwright/test';
test.describe('PIPE-07 — Season 1 full loop', () => {
test('load → begin → plant → fast-forward → harvest → reveal → journal → reload → fragment persists', async ({ page }) => {
// 1) Load with FakeClock injected (URL flag)
await page.goto('/?devtime=fake');
// 2) Begin screen visible (first run)
const beginButton = page.getByRole('button', { name: 'Begin' });
await expect(beginButton).toBeVisible({ timeout: 10000 });
// 3) Press Begin to dismiss + bootstrap audio
await beginButton.click();
await expect(beginButton).not.toBeVisible({ timeout: 5000 });
// 4) Plant a rosemary on tile 0 via the test-exposed store
await page.waitForFunction(() => Boolean((window as any).__tlgStore));
await page.evaluate(() => {
const store = (window as any).__tlgStore;
store.getState().enqueueCommand({ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' });
});
// 5) Wait for the sim to apply the plantSeed command (one tick at 5Hz = ~200ms wall, but FakeClock makes it instant)
await page.evaluate(() => (window as any).__tlgFakeClock.advance(1000));
await page.waitForFunction(
() => (window as any).__tlgStore.getState().tiles[0]?.plant?.plantTypeId === 'rosemary',
undefined,
{ timeout: 5000 },
);
// 6) Fast-forward growth past 600 ticks (5Hz × 600 = 120s = 2 minutes)
await page.evaluate(() => (window as any).__tlgFakeClock.advance(3 * 60 * 1000));
await page.waitForTimeout(500);
// 7) Enqueue harvest on tile 0
await page.evaluate(() => {
const store = (window as any).__tlgStore;
store.getState().enqueueCommand({ kind: 'harvest', tileIdx: 0 });
});
await page.evaluate(() => (window as any).__tlgFakeClock.advance(1000));
// 8) Fragment reveal modal appears with text from a Season 1 fragment
await expect(page.getByRole('dialog', { name: 'A new memory' })).toBeVisible({ timeout: 5000 });
// 9) Capture the harvested fragment id via store
const harvestedId = await page.evaluate(() => {
const store = (window as any).__tlgStore;
const ids = store.getState().harvestedFragmentIds;
return ids[ids.length - 1] as string;
});
expect(harvestedId).toMatch(/^season1\.soil\./);
// 10) Close reveal modal
await page.getByRole('button', { name: 'Close' }).first().click();
// 11) Journal icon appears in corner (D-23: reveals after first harvest)
await expect(page.getByTestId('journal-icon')).toBeVisible();
// 12) Open Journal modal and confirm fragment text is there + selectable
await page.getByTestId('journal-icon').click();
const journal = page.getByRole('dialog', { name: 'Memory Journal' });
await expect(journal).toBeVisible();
await expect(journal.locator(`[data-fragment-id="${harvestedId}"]`)).toBeVisible();
// 13) Reload — fragment must persist (CORE-04 + Phase-2 save lifecycle)
await page.reload();
// Wait for boot to complete; the begin screen should NOT appear (returning player)
await page.waitForFunction(() => Boolean((window as any).__tlgStore));
await expect(page.getByRole('button', { name: 'Begin' })).not.toBeVisible({ timeout: 5000 });
// 14) The harvested fragment is still in the store
const harvestedAfterReload = await page.evaluate(() => {
const store = (window as any).__tlgStore;
return store.getState().harvestedFragmentIds as string[];
});
expect(harvestedAfterReload).toContain(harvestedId);
// 15) Journal still shows the fragment after reload
await page.getByTestId('journal-icon').click();
await expect(page.getByRole('dialog', { name: 'Memory Journal' }).locator(`[data-fragment-id="${harvestedId}"]`)).toBeVisible();
});
});
```
**Step 3 — Verify `playwright.config.ts`** — already configured Phase 1 with `testDir: 'tests/e2e'` + dev server. Confirm the dev server is `npm run dev` and that timeout is reasonable for first-time Vite startup. (No edits expected.)
**Step 4 — Update `package.json`** to surface a quick e2e command:
```json
"test:e2e": "playwright test"
```
(Do NOT add to `ci` — Playwright is slower; it's checked manually before `/gsd-verify-work` and runs on the v1 release pipeline. Surface in SUMMARY.md.)
**Step 5 — Run the spec.**
```
npx playwright install chromium # if first run on this machine
npx playwright test
```
Iterate until green. Common adjustments:
- `data-testid` attributes may need to be added on certain components (executor adds as needed).
- Timing assertions may need tuning if FakeClock advance + sim tick rate produce delays.
- The first-time Vite dev server startup may exceed the 30s default `webServer.timeout` in playwright.config.ts; bump if needed.
**Commit:** `test(02-05): playwright e2e for PIPE-07 — full Phase-2 loop`. Run the spec to green before committing.
- `test -f tests/e2e/season1-loop.spec.ts`
- `grep -q "PIPE-07" tests/e2e/season1-loop.spec.ts`
- `grep -q "__tlgFakeClock.advance" tests/e2e/season1-loop.spec.ts`
- `grep -q "page.reload()" tests/e2e/season1-loop.spec.ts` (CORE-04 round-trip portion)
- `grep -q "test:e2e" package.json`
- `npx playwright test tests/e2e/season1-loop.spec.ts` exits 0 (full loop green)
- `__tlgFakeClock` exposed on window only when `?devtime=fake` AND non-prod (production guard verified by `import.meta.env.PROD` check)
npm run ci && npx playwright test tests/e2e/season1-loop.spec.ts
PIPE-07 Playwright spec green end-to-end. Loads game with FakeClock injected, dismisses Begin, plants rosemary, fast-forwards 3 minutes, harvests, asserts reveal modal + journal contains fragment, reloads, asserts fragment persists. `npm run ci && npx playwright test` exits 0. Phase 2 vertical slice is functionally complete.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Save round-trip boundary | wrap → CRC32 → IDB-or-LocalStorage → unwrap → CRC32 verify → migrate. Phase 1 layer. Phase 2's extended V1Payload participates without changes. |
| Boot-path offline catch-up boundary | computeOfflineCatchup clamps elapsed at 24h + refuses negative; drainTicks runs the silent simulate loop; offlineEvents accumulates. Pure pipeline. |
| FakeClock URL-flag boundary | `?devtime=fake` activates only in non-prod builds. Production-guarded via `import.meta.env.PROD === true`. Verified by PIPE-07 dev test + manual Phase-8 prod sanity. |
| Letter Ink content boundary | Repo-controlled .ink → inklecate → JSON → inkjs.Story. React renders strings. No XSS surface. |
| Settings clipboard boundary | exportToBase64 writes to navigator.clipboard via the `await writeText` API. User must grant clipboard permission; falls back gracefully if denied. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05-01 | Tampering | URL flag `?devtime=fake` exposed in production builds | mitigate | Boot path checks `import.meta.env.PROD === true` and silently ignores the flag. Asserted by PIPE-07 test guard + structural code review. |
| T-02-05-02 | Tampering | Letter Ink slot interpolation injecting HTML | mitigate | inkjs renders strings; React renders strings; no dangerouslySetInnerHTML. Slots are typed (`number | string | boolean`). |
| T-02-05-03 | Denial-of-service | Massive Base64 import via Settings | mitigate | importFromBase64 already enforces 50MB cap (Phase 1 codec.ts). Settings UI clipboard input is bounded by the textarea size. |
| T-02-05-04 | Tampering | beforeunload save race losing recent state | mitigate | Synchronous LocalStorage write fires inside the unload handler (Pitfall 7). Best-effort IDB write may lose ~1s but next-load reads whichever has more recent lastTickAt. |
| T-02-05-05 | Tampering | Compiled Ink JSON tampered post-build but pre-runtime | accept | Compiled JSON is gitignored + regenerated each build. Single-player game; no server validation. |
| T-02-05-06 | Information disclosure | Settings export reveals save data via clipboard | accept | User-initiated; the user already has DevTools access to the store. |
| T-02-05-07 | Tampering | Returning player skipping Begin via D-22 logic spoofs save existence | accept | `(await db.get('saves', 'main')) === undefined` is the gate. If a player edits localStorage to inject a fake save, the migrate path validates structure; corrupt envelope hits SaveCorruptError. |
| T-02-05-08 | Denial-of-service | 24h offline cap bypass via system-clock manipulation | mitigate | computeOfflineCatchup clamps cappedMs at MAX_OFFLINE_MS; D-11 keeps the cap silent in voice. |
No `high` severity threats.
After all 3 tasks committed:
1. **Linter:** `npm run lint` exits 0.
2. **Tests:** `npx vitest run` exits 0; new test files: `src/sim/offline/events.test.ts`, `src/sim/garden/auto-harvest.test.ts`, `src/ui/letter/letter-renderer.test.ts`, `src/ui/letter/Letter.test.tsx`, `src/ui/settings/Settings.test.tsx`. Combined Phase-1+Phase-2 test count ≥200.
3. **Ink compile:** `npm run compile:ink` produces 5 .ink.json (Plan 02-04's 4 + this plan's letter).
4. **Build:** `npm run build` exits 0.
5. **PIPE-02 verify:** `node scripts/check-bundle-split.mjs` after build exits 0.
6. **Full CI:** `npm run ci` exits 0.
7. **Playwright e2e:** `npx playwright test tests/e2e/season1-loop.spec.ts` exits 0 — proves the full Phase-2 loop end-to-end.
8. **Manual smoke** (executor performs once after all 3 tasks): `npm run dev`, plant + harvest 3+ rosemary across active play; close the tab; wait 5+ minutes (or restart with system clock advanced for testing); reopen → letter overlay displays in voice; one tap dismisses to live garden with the gate glowing if a Lura beat queued during absence.
**24 Phase-2 REQ-IDs satisfied across the 5 plans:**
| REQ-ID | Plan | Status after 02-05 |
|--------|------|---------------------|
| CORE-02 | 02-01 (scheduler) + 02-02 (Garden update loop) | ✓ |
| CORE-03 | 02-01 (catchup 24h cap) + 02-05 (boot path) | ✓ |
| CORE-11 | 02-01 (drainTicks negative refusal) | ✓ |
| GARD-01 | 02-02 (plantSeed + SeedPicker) | ✓ |
| GARD-02 | 02-02 (growth state machine) + 02-05 (save round-trip via PIPE-07) | ✓ |
| GARD-03 | 02-03 (harvest + reveal) | ✓ |
| GARD-04 | 02-03 (compost) + 02-04 (compost-acknowledgements.ink content) | ✓ (UI wiring polished here) |
| MEMR-01 | 02-03 (selector returns exactly one) | ✓ |
| MEMR-02 | 02-03 (≥10 fragments authored) | ✓ |
| MEMR-03 | 02-03 (FragmentSchema regex) | ✓ |
| MEMR-04 | 02-03 (Journal modal) | ✓ |
| MEMR-05 | 02-03 (DOM selectable) | ✓ |
| MEMR-06 | 02-03 (deterministic + gating + no-dup) | ✓ |
| STRY-01 | 02-04 (Lura Ink + dialogue overlay) | ✓ |
| STRY-06 | 02-04 (compile-ink.mjs + 4 .ink files) | ✓ |
| STRY-07 | (vacuous; no Keeper lines anywhere) | ✓ |
| STRY-10 | 02-04 (lura-gate ticks-not-time) | ✓ |
| AEST-07 | 02-02 (BeginScreen + bootstrapAudioContext) | ✓ |
| UX-01 | 02-02 (Begin no clutter) + 02-03 (Journal reveals) | ✓ |
| UX-02 | 02-05 (Letter overlay) | ✓ |
| UX-10 | 02-01 (lifecycle hooks) + 02-05 (boot wiring) | ✓ |
| UX-11 | 02-01 (BigQty.format / formatHumanReadable) | ✓ |
| PIPE-02 | 02-02 (lazy split) + 02-03 (check-bundle-split.mjs) | ✓ |
| PIPE-07 | 02-05 (Playwright spec) | ✓ |
**24 / 24 covered.**
Plan 02-05 is complete when:
- [ ] All 3 tasks committed.
- [ ] `npm run ci` exits 0.
- [ ] `npx playwright test` exits 0 — PIPE-07 green end-to-end.
- [ ] Returning player flow: close tab → wait ≥5min → reopen → see letter in voice → tap → live garden continues.
- [ ] Save lifecycle hooks fire on visibilitychange + beforeunload (UX-10 satisfied).
- [ ] Settings ships Export / Import / Restore (D-28).
- [ ] Persistence-result toast fires once if denied (D-30).
- [ ] FakeClock URL-flag injection works in dev; production-guarded.
- [ ] All 24 Phase-2 REQ-IDs structurally satisfied across the 5-plan set.
- [ ] Phase 2 vertical slice could plausibly ship as a free standalone Season 1 prologue.