63d2d8d5f7
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>
1435 lines
65 KiB
Markdown
1435 lines
65 KiB
Markdown
---
|
||
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>
|