Two distinct fields with strict separation:
- lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
the application layer. The sim NEVER writes this field.
computeOfflineCatchup uses it as the wall-clock anchor.
- tickCount: monotonic sim-internal counter (one per simulate() call).
Used for STRY-10 narrative gating that must be immune to wall-clock
manipulation. The sim writes this field; the application layer reads
it via simAdapter.applyTickCount.
Changes:
02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
simAdapter exposes applyTickCount; tests assert the round-trip.
02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
currentTick); Garden scene's SimState snapshot reads lastTickAt
through from store and writes tickCount: this.currentTick locally;
acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
02-05: buildPayloadFromStore now persists tickCount (from store);
hydrateStoreFromPayload restores it via state.setTickCount.
This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
70 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02 | 05 | execute | 2 |
|
|
true |
|
|
|
This is the integration plan: it ties offline catch-up (D-10), the letter (UX-02), save lifecycle hooks (UX-10), the Settings UI (D-28..D-30), and the Playwright e2e (PIPE-07) together — proving the full Season 1 vertical slice end-to-end.
After this plan ships, Phase 2 is functionally complete: a player can launch, plant, grow, harvest, meet Lura, leave the tab for hours, and return to a letter from the garden — and the Playwright e2e proves it persists.
3 tasks. Estimated context cost ~50%. The Playwright spec is the load-bearing closing artifact.
Land the Letter-from-the-garden vertical slice + Settings UI + save lifecycle wiring + Playwright e2e (PIPE-07) — the final Phase-2 integration. After return-from-tab-close, the player sees an authored Ink letter (D-17, D-18, UX-02) with templated insertions describing what bloomed while away (auto-harvest per D-10), what Lura did (gate beat queued during absence), and what the wind brought; one tap dismisses to the live garden. Settings menu provides Export / Import / Restore (D-28) plus the in-voice persistence-result toast (D-30). Playwright e2e exercises the entire authored loop: load → begin → plant → fast-forward → harvest → reveal → close → journal-shows-fragment → reload → fragment-persists.Purpose: Closes Phase 2. Validates that the architecture firewall holds end-to-end on real authored content + real save round-trip + real fast-forward via FakeClock injection. The PIPE-07 Playwright spec becomes the canonical proof that Phase 2 ships — /gsd-verify-work runs after this plan.
Output: A complete, working Phase-2-vertical-slice game that could plausibly ship as a free standalone Season 1 prologue. All 24 Phase-2 REQ-IDs structurally satisfied. npm run ci && npx playwright test exits 0.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom src/sim/scheduler/index.ts (Plan 02-01):
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):
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):
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):
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):
export interface OfflineEventBlock {
plantsBloomedCount: Record<string, number>;
harvestedFragmentIds: string[];
luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
}
From src/ui/dialogue (Plan 02-04):
export const InkRenderer: React.FC<{ runtime: InkRuntime; onComplete?: () => void }>;
export function createInkRuntime(story: Story): InkRuntime;
From src/content/ink-loader.ts (Plan 02-04):
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):
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 30000 },
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:
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):
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:
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:
export { autoHarvestReadyPlants } from './auto-harvest';
Step 7 — Author content/dialogue/season1/letter-from-the-garden.ink (RESEARCH Pattern 6):
// 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:
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):
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.
<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>
npm run compile:ink && npm run lint && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci
sim/offline ships Zod schema + aggregator. autoHarvestReadyPlants extends silent-mode simulate. letter-from-the-garden.ink authored in voice. ink-loader supports the letter. letter-renderer builds slots purely. All sim modules sim-pure. npm run ci green.
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: trueandpendingLetterEventBlock: null, mounts the dialog (loading state). - Dismiss button click dispatches
dismissLetter()AND callsbootstrapAudioContext(spy). - Click on backdrop dismisses; click on article does NOT.
Step 3 — src/ui/letter/index.ts:
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):
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 {
// BLOCKER 2 fix — pipeline order: importFromBase64 → unwrap (CRC verify)
// → migrate. unwrap() returns the payload directly (NOT { payload }), so
// the previous v1.payload read crashed at runtime; migrate() was also
// called against env.payload before checksum verification. Correct order:
const env = await importFromBase64(base64Buf);
const raw = unwrap(env); // verifies CRC
const { payload } = migrate(raw, env.schemaVersion); // upgrades schema
hydrateStoreFromPayload(payload as V1Payload);
setStatusLine('Restored.');
} catch (e) {
setStatusLine('That doesn\'t look like one of yours.');
}
};
const onRestoreSnapshot = async () => {
try {
const db = await openSaveDB();
const snaps = await listSnapshots(db);
if (snaps.length === 0) {
setStatusLine('Nothing earlier to find.');
return;
}
// Restore the most-recent snapshot (Phase 2 ships single-action restore;
// Phase 8 may add a list selector).
const last = snaps[snaps.length - 1];
const payload = unwrap(last.envelope);
hydrateStoreFromPayload(payload as V1Payload);
setStatusLine('Earlier garden restored.');
} catch (e) {
setStatusLine('Nothing earlier could be reached.');
}
};
return (
<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.
// BLOCKER 3 invariants:
// - lastTickAt is wall-clock ms (set here at export time via Date.now())
// - tickCount is the sim-internal monotonic counter (read from the store;
// simAdapter.applyTickCount writes it into the store every Garden.update
// so Settings.tsx can read it without coupling to the active scene)
function buildPayloadFromStore(s: ReturnType<typeof useAppStore.getState>): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt: Date.now(),
tickCount: s.tickCount ?? 0,
unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress,
offlineEvents: null,
settings: {
musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8,
persistenceToastShown: s.persistenceToastShown,
},
};
}
function hydrateStoreFromPayload(payload: V1Payload): void {
const state = useAppStore.getState();
state.applyTilesAndUnlocks(payload.garden.tiles, payload.unlockedPlantTypes);
state.setHarvested(payload.harvestedFragmentIds);
state.setLuraBeatProgress(payload.luraBeatProgress);
state.setPersistenceToastShown(payload.settings.persistenceToastShown);
// BLOCKER 3 — restore the sim's tick counter so a returning player resumes
// where they left off rather than restarting at tick 0.
state.setTickCount?.(payload.tickCount ?? 0);
}
(Phase 2 minimum-viable Settings — clipboard read/paste UX is acceptable; Phase 8 polishes.)
Step 5 — src/ui/settings/Settings.test.tsx — Vitest:
- With
open: false, returns null. - With
open: true, mounts the dialog with all four buttons. - Close button calls
onClosecallback 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):
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:
export { Settings } from './Settings';
export { PersistenceToast } from './persistence-toast';
Update src/ui/index.ts:
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:
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);
// W5 — hold the lifecycle handle so the OUTER useLayoutEffect cleanup can
// call detach(). The async IIFE that registers the hooks cannot return its
// own cleanup to the effect.
const lifecycleRef = useRef<{ detach: () => void } | null>(null);
const [persistenceToastShow, setPersistenceToastShow] = useState(false);
// Select clock: dev-time URL flag + non-prod build → FakeClock; otherwise wallClock
useLayoutEffect(() => {
const isProd = (typeof import.meta.env !== 'undefined' && (import.meta.env as { PROD?: boolean }).PROD === true);
const params = new URLSearchParams(window.location.search);
const devtime = params.get('devtime');
const useFake = !isProd && devtime === 'fake';
const fake = new FakeClock(Date.now());
(window as unknown as { __tlgClock?: unknown; __tlgFakeClock?: FakeClock }).__tlgClock = useFake ? fake : wallClock;
if (useFake) (window as unknown as { __tlgFakeClock?: FakeClock }).__tlgFakeClock = fake;
}, []);
// Boot path: load save → migrate → catch up offline → maybe open letter
useLayoutEffect(() => {
if (game.current !== null) return;
let cancelled = false;
(async () => {
const clock = ((window as unknown as { __tlgClock?: { now: () => number } }).__tlgClock) ?? wallClock;
const nowMs = clock.now();
try {
const db = await openSaveDB();
const record: SavedRecord | undefined = await db.get('saves', 'main');
if (cancelled) return;
if (record) {
// Returning player path
// BLOCKER 1 fix — boot path must run unwrap (CRC verify) THEN migrate.
// Casting unwrap(record.envelope) directly to V1Payload silently
// accepts any future-shape payload as the current shape; migrate()
// is the only place that walks the schema version chain.
const env = record.envelope;
const raw = unwrap(env);
const { payload: migratedPayload } = migrate(raw, env.schemaVersion);
const payload = migratedPayload as V1Payload;
appStore.getState().dismissBeginGate(); // D-22: skip Begin
hydrateStoreFromPayload(payload);
// Offline catch-up
const off = computeOfflineCatchup(payload.lastTickAt, nowMs);
if (off.willRunCatchup) {
// Build a SimContext for silent mode and drain ticks
const ctx = { fragments: allFragments, currentSeason: 1, silent: true } as const;
const baseSim = payload as unknown as Parameters<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).
// W5 fix — lifecycle.detach() must reach the useLayoutEffect cleanup,
// but the registration happens inside an async IIFE so the inner
// `return () => lifecycle.detach()` is the IIFE's return value, not
// the effect's cleanup. Hold the handle in a useRef declared at the
// top of the component, so the OUTER cleanup (already returned below)
// can call detach on the ref's current value.
lifecycleRef.current = registerSaveLifecycleHooks({
saveSync: () => {
try {
const state = appStore.getState();
const payload: V1Payload = buildPayloadFromStore(state, clock.now());
const env = wrap(payload, CURRENT_SCHEMA_VERSION);
// Synchronous LocalStorage path (Pitfall 7 — no await on beforeunload)
try { localStorage.setItem('tlg.saves.main', JSON.stringify(env)); } catch {}
// Best-effort IDB
void db.put('saves', { id: 'main', envelope: env });
} catch (e) {
console.warn('[saveSync] failed', e);
}
},
});
})();
return () => {
cancelled = true;
lifecycleRef.current?.detach();
lifecycleRef.current = null;
};
}, [ref]);
useEffect(() => {
const onSceneReady = (scene: Phaser.Scene) => {
sceneRef.current = scene;
props.currentActiveScene?.(scene);
};
eventBus.on('scene-ready', onSceneReady);
installFirstInteractionGestureHandler();
return () => { eventBus.off('scene-ready', onSceneReady); };
}, [props]);
useImperativeHandle(ref, () => ({
game: game.current,
scene: sceneRef.current,
}));
return (
<>
<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).
// BLOCKER 3 invariant: `lastTickAt` is WALL-CLOCK MILLISECONDS (the value of
// clock.now() at save time). The sim never writes lastTickAt — only saveSync
// in PhaserGame.tsx and the Settings export path do. computeOfflineCatchup
// reads the same wall-clock convention.
function buildPayloadFromStore(s: ReturnType<typeof appStore.getState>, lastTickAt: number): V1Payload {
return {
garden: { tiles: s.tiles },
plants: [],
harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt, // wall-clock ms, owned by saveSync
tickCount: s.tickCount ?? 0, // BLOCKER 3 — sim-internal counter from store
unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress,
offlineEvents: null,
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: s.persistenceToastShown },
};
}
function hydrateStoreFromPayload(payload: V1Payload): void {
const state = appStore.getState();
state.applyTilesAndUnlocks(payload.garden.tiles ?? new Array(16).fill(null), payload.unlockedPlantTypes ?? []);
state.setHarvested(payload.harvestedFragmentIds ?? []);
state.setLuraBeatProgress(payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null });
state.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false);
// BLOCKER 3 — restore tickCount so the sim's STRY-10 narrative gating
// resumes from the correct counter on return.
state.setTickCount?.(payload.tickCount ?? 0);
}
(NOTE: The require inside PersistenceToastWrapper will fail in ESM — replace with a top-level import. Reorganize the file so PersistenceToast imports cleanly. The above is a sketch; the executor finalizes the import order to avoid circular deps. Acceptable approach: move PersistenceToast rendering up to App.tsx and pass the show boolean via the store — see Step 11 below.)
Step 10 — Update src/game/scenes/Garden.ts to read from the externally-provided clock (no local fallback to wallClock anymore — the boot path injects via window.__tlgClock):
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:
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);
// W2 fix — D-29 keyboard shortcuts. Comma toggles Settings (a tasteful nod
// to settings being a subordinate concern; easy to reach; no browser conflict).
// 'j' toggles the Memory Journal (Plan 02-03 wires the open/close state via
// the JournalIcon's local useState — for the hotkey, dispatch a custom event
// that JournalIcon listens for, OR lift the journal-open state into the
// store. Minimum-viable: expose a window-scoped toggler from JournalIcon
// and call it here. Pick whichever feels least invasive when implementing;
// record the choice in SUMMARY.md.)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Don't trigger when the user is typing into an input/textarea
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (e.key === ',') {
e.preventDefault();
setSettingsOpen((o) => !o);
} else if (e.key === 'j' || e.key === 'J') {
e.preventDefault();
// Dispatch a window event the JournalIcon listens for (kept decoupled
// so JournalIcon owns its open/close state). JournalIcon adds a
// matching addEventListener('tlg:toggle-journal', ...) handler.
window.dispatchEvent(new CustomEvent('tlg:toggle-journal'));
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return (
<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.
<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)
- grep -q "migrate(" src/PhaserGame.tsx (BLOCKER 1: boot path runs migrate, not just unwrap)
- grep -q "migrate(" src/ui/settings/Settings.tsx (BLOCKER 2: import path runs migrate)
- grep -E "unwrap\\(.*\\)|migrate\\(" src/ui/settings/Settings.tsx | head -5 (BLOCKER 2: both unwrap and migrate appear)
- grep -q "lastTickAt: clock.now()" src/PhaserGame.tsx (BLOCKER 3: saveSync writes wall-clock ms via clock.now)
- ! grep -q "lastTickAt: this\\." src/sim/garden/Garden.ts || true (BLOCKER 3: sim does NOT write lastTickAt — Garden lives under src/game/scenes/, not src/sim/, but this guards against accidental sim-side writes)
- ! grep -E "lastTickAt: this\\.(currentTick|tickCount)" src/game/scenes/Garden.ts (BLOCKER 3: Garden scene does NOT overwrite lastTickAt with a tick counter — saveSync owns it)
- grep -q "addEventListener('keydown'" src/App.tsx (W2: D-29 keyboard shortcut wired)
- grep -q "tlg:toggle-journal" src/App.tsx (W2: journal hotkey dispatches the toggle event)
- grep -q "lifecycleRef.current?.detach()" src/PhaserGame.tsx (W5: lifecycle handle reaches outer effect cleanup)
- npx vitest run src/ui/letter/ src/ui/settings/ exits 0 with all tests green
- npm run ci exits 0
</acceptance_criteria>
npm run lint && npx vitest run src/ui/letter/ src/ui/settings/ && npm run ci
Letter overlay loads compiled letter Ink, binds slots, dismisses to live garden + bootstraps audio. Settings ships save-management surface (Export/Import/Restore). Persistence-result toast wired. PhaserGame.tsx boot path: load save → migrate → silent catch-up → maybe open letter. URL ?devtime=fake injects FakeClock; production-guarded. App.tsx mounts all overlays.
// 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:
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:
"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-testidattributes 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.timeoutin 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.
<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>
npm run ci && npx playwright test tests/e2e/season1-loop.spec.ts
PIPE-07 Playwright spec green end-to-end. Loads game with FakeClock injected, dismisses Begin, plants rosemary, fast-forwards 3 minutes, harvests, asserts reveal modal + journal contains fragment, reloads, asserts fragment persists. npm run ci && npx playwright test exits 0. Phase 2 vertical slice is functionally complete.
<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 |
| 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>
After all 3 tasks committed:
- Linter:
npm run lintexits 0. - Tests:
npx vitest runexits 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. - Ink compile:
npm run compile:inkproduces 5 .ink.json (Plan 02-04's 4 + this plan's letter). - Build:
npm run buildexits 0. - PIPE-02 verify:
node scripts/check-bundle-split.mjsafter build exits 0. - Full CI:
npm run ciexits 0. - Playwright e2e:
npx playwright test tests/e2e/season1-loop.spec.tsexits 0 — proves the full Phase-2 loop end-to-end. - 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.
<success_criteria>
Plan 02-05 is complete when:
- All 3 tasks committed.
npm run ciexits 0.npx playwright testexits 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>
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.