Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md
T
josh 63d2d8d5f7 docs(02): create phase 2 plan — 5 plans across 3 waves
Phase 2 (Season 1 Vertical Slice — Soil) plan set:
- 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule)
- 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice
- 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification
- 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10)
- 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e

All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:45:56 -04:00

1435 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<note>
**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.
</note>
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@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
<interfaces>
<!-- Types and exports from prior Phase 2 plans. -->
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<S>(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<string, number>;
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<Story>;
// ^^ 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 },
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: sim/offline + auto-harvest + extended ink-loader for letter + letter-from-the-garden.ink authoring</name>
<read_first>
- .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)
</read_first>
<files>
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
</files>
<action>
**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<typeof OfflineEventBlockSchema>;
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<typeof aggregateOfflineEvent> | 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<string, number> } | 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<Story> {
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.
</action>
<acceptance_criteria>
- `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
</acceptance_criteria>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Letter overlay + Settings + persistence-toast UIs + boot-path save lifecycle wiring + URL-flag clock injection</name>
<read_first>
- .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)
</read_first>
<files>
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
</files>
<action>
**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<InkRuntime | null>(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 (
<div
role="dialog"
aria-label="A letter from the garden"
onClick={onDismiss}
style={{
position: 'fixed', inset: 0, zIndex: 95,
background: '#0c0c0d',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<article
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: 620, padding: '3rem 2.6rem',
cursor: 'default',
}}
>
{runtime ? <InkRenderer runtime={runtime} onComplete={() => {}} /> : <p style={{ opacity: 0.4 }}>...</p>}
<button
onClick={onDismiss}
style={{
marginTop: '2rem', padding: '0.5rem 1.6rem',
background: 'transparent', color: '#e8e0d0',
border: '1px solid #e8e0d0', cursor: 'pointer',
fontFamily: 'serif',
}}
>
Tend the garden
</button>
</article>
</div>
);
}
```
**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<string | null>(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 {
const env = await importFromBase64(base64Buf);
const { payload } = migrate(env.payload, env.schemaVersion);
const v1 = unwrap(env);
const restored = v1.payload as V1Payload; // post-migrate
// Apply to store
hydrateStoreFromPayload(restored);
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 (
<div role="dialog" aria-label={strings.title} style={{
position: 'fixed', inset: 0, zIndex: 70,
background: '#1a1a1ac0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#e8e0d0', fontFamily: 'serif',
}}>
<div style={{ maxWidth: 520, background: '#1f1f23', padding: '2rem', borderRadius: 4 }}>
<h2 style={{ marginTop: 0, fontWeight: 300, letterSpacing: '0.1em' }}>{strings.title}</h2>
<button onClick={onExport} style={btnStyle}>{strings.export}</button>
<textarea
value={base64Buf}
onChange={(e) => setBase64Buf(e.target.value)}
rows={4}
style={{ width: '100%', marginTop: '1rem', fontFamily: 'monospace', fontSize: '0.8rem' }}
aria-label="Save data"
/>
<button onClick={onImport} style={btnStyle}>{strings.import}</button>
<button onClick={onRestoreSnapshot} style={btnStyle}>{strings.restore_snapshot}</button>
{statusLine && <p style={{ opacity: 0.6, fontStyle: 'italic' }}>{statusLine}</p>}
<button onClick={onClose} style={{ ...btnStyle, marginTop: '1.5rem' }}>Close</button>
</div>
</div>
);
}
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
function buildPayloadFromStore(s: ReturnType<typeof useAppStore.getState>): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt: Date.now(),
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);
}
```
(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 (
<div
role="status"
style={{
position: 'fixed', bottom: 24, left: 24, zIndex: 30,
maxWidth: 420, padding: '0.8rem 1.2rem',
background: '#1f1f23ee', color: '#e8e0d0',
border: '1px solid #4d4d52', fontFamily: 'serif',
fontStyle: 'italic',
}}
>
{strings.persistence_denied_toast}
</div>
);
}
```
(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<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
const game = useRef<Phaser.Game | null>(null);
const sceneRef = useRef<Phaser.Scene | 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
const payload = unwrap(record.envelope) 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<typeof simulateOneTick>[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)
const lifecycle = 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);
}
},
});
// Cleanup on unmount
return () => lifecycle.detach();
})();
return () => { cancelled = true; };
}, [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 (
<>
<div id="game-container" />
{persistenceToastShow && <PersistenceToastWrapper />}
</>
);
});
// 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 <ToastModule.PersistenceToast show={true} />;
}
// Helpers (mirror Settings.tsx — deduplicate by extracting if reused)
function buildPayloadFromStore(s: ReturnType<typeof appStore.getState>, lastTickAt: number): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt,
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);
}
```
(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<IRefPhaserGame | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const persistenceToastShown = useAppStore((s) => s.persistenceToastShown);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<SeedPicker />
<FragmentRevealModal />
<JournalIcon />
<LuraDialogue />
<Letter />
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PersistenceToast show={!persistenceToastShown && false /* PhaserGame controls show via state via store */} />
<button
data-testid="settings-icon"
aria-label="Open settings"
onClick={() => setSettingsOpen(true)}
style={{
position: 'fixed', bottom: 20, right: 76, zIndex: 40,
width: 44, height: 44, borderRadius: 22,
background: '#2a2a2e', color: '#e8e0d0',
border: '1px solid #4d4d52', cursor: 'pointer',
fontFamily: 'serif', fontSize: '1.2rem',
}}
>
</button>
</div>
);
}
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 `<PersistenceToast show={persistShow} />` 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.
</action>
<acceptance_criteria>
- `grep -q "Letter from the garden" content/dialogue/season1/letter-from-the-garden.ink` (header comment)
- `grep -q "<Letter />" src/App.tsx` and `grep -q "<Settings " src/App.tsx`
- `grep -q "loadInkStory('letter-from-the-garden')" src/ui/letter/Letter.tsx`
- `grep -q "void bootstrapAudioContext()" src/ui/letter/Letter.tsx` (Pitfall 9 mitigation)
- `grep -q "exportToBase64" src/ui/settings/Settings.tsx`
- `grep -q "importFromBase64" src/ui/settings/Settings.tsx`
- `grep -q "listSnapshots" src/ui/settings/Settings.tsx`
- `grep -q "computeOfflineCatchup" src/PhaserGame.tsx`
- `grep -q "registerSaveLifecycleHooks" src/PhaserGame.tsx`
- `grep -q "ABSENCE_LETTER_THRESHOLD_MS = 5" src/PhaserGame.tsx`
- `grep -q "__tlgFakeClock" src/PhaserGame.tsx` (URL flag wired)
- `grep -q "import.meta.env" src/PhaserGame.tsx` (production guard for FakeClock)
- `npx vitest run src/ui/letter/ src/ui/settings/` exits 0 with all tests green
- `npm run ci` exits 0
</acceptance_criteria>
<verify>
<automated>npm run lint && npx vitest run src/ui/letter/ src/ui/settings/ && npm run ci</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 3: Playwright e2e (PIPE-07) — full Phase-2 loop smoke test</name>
<read_first>
- .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)
</read_first>
<files>
tests/e2e/season1-loop.spec.ts,
playwright.config.ts,
src/PhaserGame.tsx,
package.json
</files>
<action>
**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<string, unknown>).__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.
</action>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<verify>
<automated>npm run ci && npx playwright test tests/e2e/season1-loop.spec.ts</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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.
</threat_model>
<verification>
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.**
</verification>
<success_criteria>
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.
</success_criteria>
<output>
Create `.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-SUMMARY.md` per template. Document:
- Final 5-minute absence threshold (D-20) — verify in PhaserGame.tsx const.
- Whether the URL-flag FakeClock injection landed cleanly (or needed work).
- Compost-beat UI wiring approach (re-used dialogue overlay vs new toast vs persistent TODO).
- Playwright run time on the dev machine (informational; goal is <30s).
- Manual smoke test confirmation — full Phase-2 loop in dev build.
- Final tally of all 24 Phase-2 REQ-IDs (with the plan that owned each).
- Any deviations / Phase-3 follow-ups discovered during integration (e.g., Settings clipboard UX polish, audio bus stub).
- Confirmation that Phase 1's 53 tests + all Phase-2 additions all run green: total test count.
</output>