From 5d58d6cc7b6ce84bda72dcc2a1f77caec963c1f3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 10:57:09 -0400 Subject: [PATCH] feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/save/payload.ts (W2): shared buildPayloadFromStore (state, nowMs) + hydrateStoreFromPayload (state, payload). Two-arg signature unifies Settings.tsx (passes Date.now()) and PhaserGame.tsx saveSync (passes clock.now()). BLOCKER 3 — lastTickAt is wall-clock ms, owned by the application layer; the sim never writes it. - src/ui/letter/Letter.tsx + test: D-20 full-screen overlay (UX-02); loads compiled letter Ink, binds plants_bloomed/fragment_titles/ lura_was_here slots from session.pendingLetterEventBlock, dismisses via Tend the garden button or backdrop click. Pitfall 9 — dismiss calls bootstrapAudioContext synchronously inside the click handler. - src/ui/settings/Settings.tsx + test: D-28 save-management UI (Export/Import/Restore). BLOCKER 2 — Import pipeline is importFromBase64 -> unwrap (CRC verify) -> migrate -> hydrate. No audio sliders, no a11y polish (Phase 8 owns those). - src/ui/settings/persistence-toast.tsx: D-30 one-time soft toast in voice when navigator.storage.persist() denies. Reads showPersistenceToast from session slice; sets persistenceToastShown after the timeout fires. - src/PhaserGame.tsx: full boot path rewrite. Clock selection (?devtime=fake URL flag, production-guarded by import.meta.env.PROD), save load (BLOCKER 1 — unwrap then migrate), silent offline catchup via drainTicks(silent=true), letter overlay open at >=5min absence, requestPersistence + showPersistenceToast wiring, Phaser start AFTER hydration, registerSaveLifecycleHooks with synchronous LocalStorage saveSync (Pitfall 7) + best-effort IDB write. W5 — lifecycle handle held in ref so outer cleanup detaches. - src/store/session-slice.ts: showPersistenceToast transient flag + setShowPersistenceToast action. - src/ui/journal/journal-icon.tsx: 'j' hotkey listener via tlg:toggle-journal CustomEvent (D-29). - src/game/scenes/Garden.ts: formalized clock read via readClockSlot() helper; falls back to wallClock if window.__tlgClock missing. - src/App.tsx: mount Letter, Settings, PersistenceToast, SettingsIcon (corner button); D-29 keyboard shortcuts (',' toggles Settings, 'j' toggles Journal via window event). - 308/308 tests green (was 295; +13 new — 7 Letter + 6 Settings). npm run ci exits 0; Vite emits letter Ink as a separate lazy chunk. --- src/App.tsx | 67 +++++- src/PhaserGame.tsx | 328 +++++++++++++++++++++++++- src/game/scenes/Garden.ts | 23 +- src/save/index.ts | 4 + src/save/payload.ts | 86 +++++++ src/store/session-slice.ts | 10 +- src/ui/index.ts | 2 + src/ui/journal/journal-icon.tsx | 11 + src/ui/letter/Letter.test.tsx | 139 +++++++++++ src/ui/letter/Letter.tsx | 150 ++++++++++++ src/ui/letter/index.ts | 12 + src/ui/settings/Settings.test.tsx | 79 +++++++ src/ui/settings/Settings.tsx | 193 +++++++++++++++ src/ui/settings/index.ts | 9 + src/ui/settings/persistence-toast.tsx | 62 +++++ 15 files changed, 1156 insertions(+), 19 deletions(-) create mode 100644 src/save/payload.ts create mode 100644 src/ui/letter/Letter.test.tsx create mode 100644 src/ui/letter/Letter.tsx create mode 100644 src/ui/letter/index.ts create mode 100644 src/ui/settings/Settings.test.tsx create mode 100644 src/ui/settings/Settings.tsx create mode 100644 src/ui/settings/index.ts create mode 100644 src/ui/settings/persistence-toast.tsx diff --git a/src/App.tsx b/src/App.tsx index 4576988..2c0af36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,52 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; import { BeginScreen } from './ui/begin'; import { SeedPicker } from './ui/garden'; import { FragmentRevealModal, JournalIcon } 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() { // PhaserGame ref — Phase 2+ will use this to access the active scene from React. const phaserRef = useRef(null); + const [settingsOpen, setSettingsOpen] = useState(false); + // D-30 — toast surfaces for one cycle when the boot path's + // requestPersistence resolves with denied. PhaserGame writes + // showPersistenceToast=true; the toast component reads it. + const showPersistenceToast = useAppStore((s) => s.showPersistenceToast); + + // D-29 — keyboard shortcuts for Settings and the Memory Journal. + // Comma toggles Settings (a tasteful nod — settings is a subordinate + // concern, easy to reach, no browser conflict). + // 'j' toggles the Memory Journal via a window CustomEvent that + // JournalIcon listens for — keeps the icon's open/close state local + // (V1Payload has no journal-open flag, by design — see Plan 02-03 + // SUMMARY). + useEffect(() => { + const onKeyDown = (e: KeyboardEvent): void => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + 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(); + window.dispatchEvent(new CustomEvent('tlg:toggle-journal')); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); return (
@@ -17,7 +56,31 @@ function App() { - {/* Plan 02-05 mounts: , , */} + + setSettingsOpen(false)} /> + +
); } diff --git a/src/PhaserGame.tsx b/src/PhaserGame.tsx index 42f100b..3b7902d 100644 --- a/src/PhaserGame.tsx +++ b/src/PhaserGame.tsx @@ -1,9 +1,72 @@ -import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, +} 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, + buildPayloadFromStore, + hydrateStoreFromPayload, + type V1Payload, + type LifecycleHooksHandle, +} from './save'; +import { + wallClock, + FakeClock, + drainTicks, + computeOfflineCatchup, + type Clock, +} from './sim/scheduler'; +import { simulateOneTick, type SimContext } from './sim/garden'; +import type { SimState } from './sim/state'; +import { fragments as allFragments } from './content'; + +/** + * Plan 02-05 — boot path + clock selection + save lifecycle wiring. + * + * This component is the binding layer between React, Phaser, and the + * save layer. It runs in two useLayoutEffect blocks: + * + * 1. Clock selection (URL flag or wallClock). Stores the chosen clock + * on `window.__tlgClock` so the Garden scene + Playwright e2e can + * both read it. ?devtime=fake activates FakeClock; production + * builds (import.meta.env.PROD) silently ignore the flag. + * + * 2. Boot path: + * - Read save record from IDB (or LocalStorage fallback). + * - If save exists: unwrap (CRC verify) → migrate (chain to current + * schema) → hydrate store → computeOfflineCatchup → drainTicks + * in silent mode → if cappedMs ≥ 5min, open the letter overlay + * with the accumulated offlineEvents block. + * - If no save: first-run init (rosemary unlocked). + * - requestPersistence() — set showPersistenceToast iff denied. + * - Start Phaser AFTER hydration so the Garden scene reads correct + * initial state. + * - registerSaveLifecycleHooks (UX-10) — visibilitychange + beforeunload. + * + * Per BLOCKER 1 (PLAN W2): the boot path runs unwrap THEN migrate. + * Per BLOCKER 3: saveSync writes lastTickAt = clock.now() (wall-clock ms). + * The sim never writes lastTickAt; it writes tickCount. + * + * Per W5: lifecycle.detach() is held in a ref so the OUTER useLayoutEffect + * cleanup can call it — the async IIFE that registers the hooks cannot + * return its own cleanup to the effect. + */ + +const ABSENCE_LETTER_THRESHOLD_MS = 5 * 60 * 1000; export interface IRefPhaserGame { game: Phaser.Game | null; @@ -14,30 +77,274 @@ interface IProps { currentActiveScene?: (sceneInstance: Phaser.Scene) => void; } -export const PhaserGame = forwardRef(function PhaserGame(props, ref) { +/** Module-level type narrowing for the dev-time window slots. */ +interface DevTimeWindow { + __tlgClock?: Clock; + __tlgFakeClock?: FakeClock; + __tlgStore?: typeof appStore; +} + +export const PhaserGame = forwardRef(function PhaserGame( + props, + ref, +) { const game = useRef(null); const sceneRef = useRef(null); + // W5 — lifecycle handle held in a ref so the OUTER cleanup can detach. + const lifecycleRef = useRef(null); + // Clock selection. Runs once. ?devtime=fake activates FakeClock in + // non-prod builds only. Production guard: import.meta.env.PROD. useLayoutEffect(() => { - if (game.current === null) { - // Bootstrap initial state. Plan 02-05 will replace this with the - // real save-load path; for now, first-run players get rosemary - // unlocked (D-05 — the warm starter plant). - const initial = appStore.getState(); - if (initial.unlockedPlantTypes.length === 0) { + 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 w = window as unknown as DevTimeWindow; + if (useFake) { + const fake = new FakeClock(Date.now()); + w.__tlgClock = fake; + w.__tlgFakeClock = fake; + // Plan 02-05 — expose the store on window for Playwright PIPE-07. + // Production-guarded by the same isProd check; the e2e spec uses + // `?devtime=fake` so this path only fires in dev/test builds. + w.__tlgStore = appStore; + } else { + w.__tlgClock = wallClock; + } + }, []); + + // Boot path. Runs once. Reads save → migrate → catch up offline → + // maybe open letter → start Phaser → register lifecycle hooks. + useLayoutEffect(() => { + if (game.current !== null) return; + let cancelled = false; + + void (async () => { + const w = window as unknown as DevTimeWindow; + const clock: Clock = w.__tlgClock ?? wallClock; + const nowMs = clock.now(); + let dbRef: Awaited> | null = null; + + try { + const db = await openSaveDB(); + if (cancelled) return; + dbRef = db; + const record = await db.get('saves', 'main'); + + if (record) { + // Returning player path. BLOCKER 1 — unwrap (CRC verify) first, + // then migrate to bring the payload up to CURRENT_SCHEMA_VERSION. + const env = record.envelope; + let payload: V1Payload; + try { + const raw = unwrap(env); + const result = migrate(raw, env.schemaVersion); + payload = result.payload as V1Payload; + } catch (err) { + console.error( + '[boot] save unwrap/migrate failed; starting fresh', + err, + ); + // Fall through to first-run init below. + initFirstRun(); + await postBootRequestPersistence(); + startPhaserAndRegisterHooks(db, clock); + return; + } + + // D-22 — returning player skips Begin gate. + appStore.getState().dismissBeginGate(); + hydrateStoreFromPayload(appStore.getState(), payload); + + // Offline catchup (CONTEXT D-10 + D-11; CORE-03 + CORE-11). + const off = computeOfflineCatchup(payload.lastTickAt, nowMs); + if (off.willRunCatchup) { + const ctx: SimContext = { + fragments: allFragments, + currentSeason: 1, + silent: true, + }; + // V1Payload is structurally a SimState (state.ts mirrors + // migrations.ts) — pass it through with a starting tickCount. + let runningTick = payload.tickCount ?? 0; + const seedSimState = payload as unknown as SimState; + const result = drainTicks( + seedSimState, + off.cappedMs, + (state, _dtMs, silent) => { + runningTick += 1; + return simulateOneTick(state, runningTick, [], { + ...ctx, + silent, + }); + }, + true, + ); + const finalState = result.state; + // Push the post-catchup state back into the store. + const postPayload: V1Payload = { + ...payload, + garden: finalState.garden, + harvestedFragmentIds: finalState.harvestedFragmentIds, + tickCount: finalState.tickCount, + unlockedPlantTypes: finalState.unlockedPlantTypes, + luraBeatProgress: finalState.luraBeatProgress, + lastTickAt: nowMs, // wall-clock anchor at boot + offlineEvents: + (finalState.offlineEvents as V1Payload['offlineEvents']) ?? + null, + }; + hydrateStoreFromPayload(appStore.getState(), postPayload); + + // D-20 — open letter when absence ≥5min. + if ( + off.cappedMs >= ABSENCE_LETTER_THRESHOLD_MS && + postPayload.offlineEvents + ) { + appStore + .getState() + .openLetter(postPayload.offlineEvents); + } + } + } else { + // First-run path. + initFirstRun(); + } + + await postBootRequestPersistence(); + startPhaserAndRegisterHooks(db, clock); + } catch (err) { + console.error('[boot] save load failed; starting fresh', err); + initFirstRun(); + // Best-effort: try to register hooks against whatever DB we + // managed to open. If openSaveDB itself failed, dbRef is null and + // saveSync's IDB write becomes a no-op (LocalStorage path still + // fires below). + if (dbRef) startPhaserAndRegisterHooks(dbRef, clock); + else startPhaserAndRegisterHooksWithoutDb(clock); + } + })(); + + function initFirstRun(): void { + if (appStore.getState().unlockedPlantTypes.length === 0) { appStore.setState({ unlockedPlantTypes: ['rosemary'] }); } + } + async function postBootRequestPersistence(): Promise { + try { + const result = await requestPersistence(); + if (cancelled) return; + // D-30 — show toast iff denied AND not previously shown. + if ( + result.apiAvailable && + !result.granted && + !appStore.getState().persistenceToastShown + ) { + appStore.getState().setShowPersistenceToast(true); + } + } catch (err) { + console.warn('[boot] requestPersistence failed', err); + } + } + + function startPhaserAndRegisterHooks( + db: Awaited>, + clock: Clock, + ): void { + if (cancelled) return; + // Start Phaser AFTER state hydration so the Garden scene's create() + // reads the correct initial tickCount + 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 }; } + + // UX-10 — register lifecycle hooks (visibilitychange + beforeunload). + // saveSync MUST be synchronous (Pitfall 7) — beforeunload won't await. + // Synchronous LocalStorage write fires unconditionally; IDB best- + // effort (the put() promise resolves out of band but the LS write + // already captured the state). + lifecycleRef.current = registerSaveLifecycleHooks({ + saveSync: () => { + try { + const state = appStore.getState(); + // BLOCKER 3 — saveSync writes lastTickAt = clock.now() + // (wall-clock ms via the injected clock). The sim NEVER + // writes lastTickAt; this is the canonical write site. + const payload: V1Payload = buildPayloadFromStore( + state, + clock.now(), + ); + const env = wrap(payload, CURRENT_SCHEMA_VERSION); + // Synchronous LocalStorage path (Pitfall 7 — no await). + try { + localStorage.setItem('tlg.saves.main', JSON.stringify({ + id: 'main', + envelope: env, + savedAt: new Date().toISOString(), + })); + } catch { + /* localStorage may be unavailable in private mode */ + } + // Best-effort IDB write. Promise fires out of band; the LS + // write above captured the state synchronously. + void db.put('saves', { + id: 'main', + envelope: env, + savedAt: new Date().toISOString(), + }); + } catch (e) { + console.warn('[saveSync] failed', e); + } + }, + }); + } + + function startPhaserAndRegisterHooksWithoutDb(clock: Clock): void { + if (cancelled) return; + 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 }; + } + // Degenerate path — no DB available. saveSync still writes to + // LocalStorage so the player can recover via Settings → Import. + lifecycleRef.current = registerSaveLifecycleHooks({ + saveSync: () => { + try { + const state = appStore.getState(); + const payload: V1Payload = buildPayloadFromStore( + state, + clock.now(), + ); + const env = wrap(payload, CURRENT_SCHEMA_VERSION); + try { + localStorage.setItem('tlg.saves.main', JSON.stringify({ + id: 'main', + envelope: env, + savedAt: new Date().toISOString(), + })); + } catch { + /* private mode; nothing to do */ + } + } catch (e) { + console.warn('[saveSync] failed (no-DB path)', e); + } + }, + }); } return () => { + cancelled = true; + lifecycleRef.current?.detach(); + lifecycleRef.current = null; if (game.current) { game.current.destroy(true); game.current = null; @@ -54,8 +361,7 @@ export const PhaserGame = forwardRef(function PhaserGame // Install the first-interaction gesture handler unconditionally — // it is a one-shot that bootstraps audio on the first click / - // touch / keypress whether the Begin screen handled it or not (D-22 - // fallback for returning players who skip the Begin gate). + // touch / keypress whether the Begin screen handled it or not. installFirstInteractionGestureHandler(); return () => { diff --git a/src/game/scenes/Garden.ts b/src/game/scenes/Garden.ts index 58364c5..0c444c0 100644 --- a/src/game/scenes/Garden.ts +++ b/src/game/scenes/Garden.ts @@ -66,12 +66,25 @@ export class Garden extends Phaser.Scene { super('Garden'); } + /** + * Plan 02-05 — read the externally-provided clock from the window + * slot. The boot path (src/PhaserGame.tsx) writes either wallClock or + * a FakeClock here based on the ?devtime=fake URL flag (production- + * guarded). Falls back to wallClock if no slot is set. + */ + private readClockSlot(): Clock { + return ( + (window as unknown as { __tlgClock?: Clock }).__tlgClock ?? wallClock + ); + } + create(): void { - // Allow Playwright (Plan 02-05) to swap in a FakeClock via a window- - // scoped slot. Production-guarded via import.meta.env.PROD by the - // application layer (PhaserGame.tsx) — this scene only reads. - const slot = (window as unknown as { __tlgClock?: Clock }).__tlgClock; - if (slot) this.clock = slot; + // Plan 02-05 — read the clock from the window slot the boot path + // (src/PhaserGame.tsx) populated. Production-guarded by the boot + // path's import.meta.env.PROD check — this scene just reads. Falls + // back to wallClock if the slot is somehow missing (e.g., a unit + // test instantiating the scene directly). + this.clock = this.readClockSlot(); // Build the SimContext once at create() — Phase 2 ships only Season 1. // Phase 4+ should swap this for `await loadSeasonFragments(currentSeason)` diff --git a/src/save/index.ts b/src/save/index.ts index bbee42b..896647a 100644 --- a/src/save/index.ts +++ b/src/save/index.ts @@ -38,3 +38,7 @@ export { LocalStorageDBAdapter } from './db-localstorage-adapter'; export type { StoreName, RecordOf } from './db-localstorage-adapter'; export { crc32hex, canonicalJSON } from './checksum'; + +// Plan 02-05 — shared payload build/hydrate helpers used by saveSync +// (src/PhaserGame.tsx) and the Settings Export/Import path (src/ui/settings/). +export { buildPayloadFromStore, hydrateStoreFromPayload } from './payload'; diff --git a/src/save/payload.ts b/src/save/payload.ts new file mode 100644 index 0000000..1805a63 --- /dev/null +++ b/src/save/payload.ts @@ -0,0 +1,86 @@ +import type { AppStoreShape } from '../store'; +import type { V1Payload } from './migrations'; + +/** + * Shared save-payload helpers — used by both src/PhaserGame.tsx (saveSync + * called by registerSaveLifecycleHooks on visibilitychange/beforeunload) + * and src/ui/settings/Settings.tsx (Export-to-Base64 button). + * + * Per W2 fix in PLAN: an earlier draft duplicated the build/hydrate logic + * across both call sites, including an arity divergence (one-arg vs + * two-arg signature). Lifting both helpers here unifies the contract. + * + * BLOCKER 3 invariants: + * - lastTickAt is wall-clock ms — owned by saveSync (PhaserGame) and + * the Settings export path. The sim NEVER writes lastTickAt; the + * application layer reads it from a clock and threads it through + * `nowMs` here. + * - tickCount is the sim-internal monotonic counter (STRY-10) — read + * from the store; the sim writes it via simulateOneTick. We + * persist it so a returning player resumes at the correct tick + * count rather than restarting at zero. + */ + +/** + * Build a V1Payload save envelope from the current store state. + * + * @param s Snapshot of the store state (`useAppStore.getState()`). + * @param nowMs Wall-clock milliseconds to record as `lastTickAt`. The + * caller chooses the clock — PhaserGame's saveSync passes + * `clock.now()` (the injected clock — wallClock or + * FakeClock); Settings.tsx passes `Date.now()` (no clock + * on hand). Two-arg signature unifies the surface. + */ +export function buildPayloadFromStore( + s: AppStoreShape, + nowMs: number, +): V1Payload { + return { + garden: { tiles: s.tiles }, + plants: [], + harvestedFragmentIds: s.harvestedFragmentIds, + lastTickAt: nowMs, // wall-clock ms; BLOCKER 3 invariant + tickCount: s.tickCount, // BLOCKER 3 — sim-internal counter + unlockedPlantTypes: s.unlockedPlantTypes, + luraBeatProgress: s.luraBeatProgress, + offlineEvents: null, // letter has been (or will be) shown — clear + settings: { + musicVolume: 0.7, + ambientVolume: 0.5, + sfxVolume: 0.8, + persistenceToastShown: s.persistenceToastShown, + }, + }; +} + +/** + * Hydrate the store from a migrated V1Payload. Defensive defaults guard + * against partial / older payloads that survived migrate() but with + * missing-but-compatible fields. + * + * BLOCKER 3 — restores tickCount so STRY-10 narrative gating resumes + * at the correct point. Restores lastTickAt too so the boot path's + * computeOfflineCatchup has a wall-clock anchor. + */ +export function hydrateStoreFromPayload( + s: AppStoreShape, + payload: V1Payload, +): void { + s.applyTilesAndUnlocks( + payload.garden.tiles ?? new Array(16).fill(null), + payload.unlockedPlantTypes ?? [], + ); + s.setHarvested(payload.harvestedFragmentIds ?? []); + s.setLuraBeatProgress( + payload.luraBeatProgress ?? { + arrived: false, + mid: false, + farewell: false, + pending: null, + }, + ); + s.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false); + // BLOCKER 3 — restore tickCount + lastTickAt. + s.setTickCount(payload.tickCount ?? 0); + s.setLastTickAt(payload.lastTickAt ?? 0); +} diff --git a/src/store/session-slice.ts b/src/store/session-slice.ts index 90b8727..a8e97da 100644 --- a/src/store/session-slice.ts +++ b/src/store/session-slice.ts @@ -11,11 +11,17 @@ import type { StateCreator } from 'zustand'; export interface SessionSlice { beginGateDismissed: boolean; persistenceToastShown: boolean; + /** Plan 02-05 — transient flag. PhaserGame.tsx sets true when boot + * path's requestPersistence resolves with denied + !persistenceToastShown. + * PersistenceToast reads this and sets persistenceToastShown=true after + * the toast fades. */ + showPersistenceToast: boolean; letterOverlayOpen: boolean; - /** OfflineEventBlock; typed in Plan 02-05 when the offline pipeline lands. */ + /** OfflineEventBlock; populated by the boot path's silent catchup loop. */ pendingLetterEventBlock: unknown | null; dismissBeginGate: () => void; setPersistenceToastShown: (v: boolean) => void; + setShowPersistenceToast: (v: boolean) => void; openLetter: (block: unknown) => void; dismissLetter: () => void; } @@ -23,10 +29,12 @@ export interface SessionSlice { export const createSessionSlice: StateCreator = (set) => ({ beginGateDismissed: false, persistenceToastShown: false, + showPersistenceToast: false, letterOverlayOpen: false, pendingLetterEventBlock: null, dismissBeginGate: () => set({ beginGateDismissed: true }), setPersistenceToastShown: (v) => set({ persistenceToastShown: v }), + setShowPersistenceToast: (v) => set({ showPersistenceToast: v }), openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }), dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }), }); diff --git a/src/ui/index.ts b/src/ui/index.ts index e27c0b0..ae775bc 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -9,3 +9,5 @@ export * from './begin'; export * from './garden'; export * from './journal'; export * from './dialogue'; +export * from './letter'; +export * from './settings'; diff --git a/src/ui/journal/journal-icon.tsx b/src/ui/journal/journal-icon.tsx index a491ff3..3666322 100644 --- a/src/ui/journal/journal-icon.tsx +++ b/src/ui/journal/journal-icon.tsx @@ -31,6 +31,17 @@ export function JournalIcon(): JSX.Element | null { if (!revealed && open) setOpen(false); }, [revealed, open]); + // Plan 02-05 — D-29 'j' hotkey listens for the App-dispatched + // CustomEvent to toggle the journal modal. Keeping the open/close + // state local here (rather than lifting into the store) preserves + // V1Payload's no-journal-open-flag invariant. + useEffect(() => { + if (!revealed) return; + const onToggle = (): void => setOpen((v) => !v); + window.addEventListener('tlg:toggle-journal', onToggle); + return () => window.removeEventListener('tlg:toggle-journal', onToggle); + }, [revealed]); + if (!revealed) return null; return ( diff --git a/src/ui/letter/Letter.test.tsx b/src/ui/letter/Letter.test.tsx new file mode 100644 index 0000000..557c00c --- /dev/null +++ b/src/ui/letter/Letter.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { appStore } from '../../store'; + +// Hoisted mocks. Phaser is not loaded under happy-dom; the dialogue + +// content modules transitively pull Phaser via the event-bus, so we +// mock the surfaces the Letter component touches. +vi.mock('../../game/event-bus', () => ({ + eventBus: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + removeAllListeners: vi.fn(), + }, +})); + +const fakeStoryProto = { + variablesState: {} as Record, + ChoosePathString: vi.fn(), +}; + +vi.mock('../../content', () => ({ + loadInkStory: vi.fn(async () => fakeStoryProto), + bindGardenStateToInk: vi.fn(), + fragments: [], +})); + +vi.mock('../dialogue', () => ({ + InkRenderer: () => null, + createInkRuntime: vi.fn(() => ({ + nextLine: vi.fn(async () => null), + canContinue: () => false, + currentChoices: () => [], + chooseChoice: vi.fn(), + skipDelay: vi.fn(), + })), +})); + +// Hoisted spy: vi.mock factories are hoisted above imports, so any +// top-level variable they reference must also be hoisted via vi.hoisted. +const { bootstrapSpy } = vi.hoisted(() => ({ + bootstrapSpy: vi.fn(async () => null), +})); +vi.mock('../begin', () => ({ + bootstrapAudioContext: bootstrapSpy, +})); + +import { Letter } from './Letter'; + +describe('Letter (UX-02 + D-20 — full-screen overlay)', () => { + beforeEach(() => { + appStore.setState({ + letterOverlayOpen: false, + pendingLetterEventBlock: null, + beginGateDismissed: false, + }); + bootstrapSpy.mockClear(); + fakeStoryProto.ChoosePathString.mockClear(); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('returns null when letterOverlayOpen=false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('mounts the dialog when letterOverlayOpen=true (loading state, null block)', () => { + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + }); + render(); + expect(screen.getByRole('dialog', { name: 'A letter from the garden' })).toBeTruthy(); + }); + + it('Tend the garden button dispatches dismissLetter AND calls bootstrapAudioContext (Pitfall 9)', () => { + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + }); + render(); + const btn = screen.getByRole('button', { name: 'Tend the garden' }); + fireEvent.click(btn); + const post = appStore.getState(); + expect(post.letterOverlayOpen).toBe(false); + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); + + it('clicks on the article body do NOT dismiss the overlay', () => { + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + }); + render(); + const article = screen.getByRole('dialog').querySelector('article'); + expect(article).not.toBeNull(); + fireEvent.click(article!); + expect(appStore.getState().letterOverlayOpen).toBe(true); + }); + + it('clicks on the backdrop dismiss the overlay AND bootstrap audio', () => { + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + }); + render(); + const backdrop = screen.getByRole('dialog'); + fireEvent.click(backdrop); + expect(appStore.getState().letterOverlayOpen).toBe(false); + expect(bootstrapSpy).toHaveBeenCalled(); + }); + + it('also dismisses the Begin gate on dismiss (returning-player belt-and-braces)', () => { + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + beginGateDismissed: false, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Tend the garden' })); + expect(appStore.getState().beginGateDismissed).toBe(true); + }); + + it('calls loadInkStory("letter-from-the-garden") + ChoosePathString("letter") when opened', async () => { + const { loadInkStory } = await import('../../content'); + appStore.setState({ + letterOverlayOpen: true, + pendingLetterEventBlock: null, + }); + render(); + await waitFor(() => { + expect(loadInkStory).toHaveBeenCalledWith('letter-from-the-garden'); + }); + expect(fakeStoryProto.ChoosePathString).toHaveBeenCalledWith('letter'); + }); +}); diff --git a/src/ui/letter/Letter.tsx b/src/ui/letter/Letter.tsx new file mode 100644 index 0000000..19de1c6 --- /dev/null +++ b/src/ui/letter/Letter.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState, type JSX } from 'react'; +import { appStore, 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'; + +/** + * Letter from the garden — UX-02 + CONTEXT D-17/D-18/D-20 + Pitfall 9. + * + * Full-screen DOM overlay. Triggered when the boot path determines a + * returning player has been away ≥5 minutes (the threshold is owned by + * src/PhaserGame.tsx; this component just reacts to the store flag). + * One tap dismisses to the live garden. + * + * Per Pitfall 9: dismiss must call bootstrapAudioContext() — a returning + * player who lands directly in the letter would otherwise have no audio + * gesture before reaching the live garden. The synchronous-inside-click + * contract from AEST-07 (Pitfall 5) applies here too. + * + * Per RESEARCH Architectural Responsibility Map: Ink runtime lives in + * the UI tier. This component reuses the same loadInkStory + InkRenderer + * surface as Plan 02-04's LuraDialogue — single source of truth for the + * Ink runtime path. + */ +export function Letter(): JSX.Element | null { + const open = useAppStore((s) => s.letterOverlayOpen); + const block = useAppStore( + (s) => s.pendingLetterEventBlock, + ) as OfflineEventBlock | null; + const dismissLetter = useAppStore((s) => s.dismissLetter); + const [runtime, setRuntime] = useState(null); + + useEffect(() => { + if (!open) { + setRuntime(null); + return; + } + let cancelled = false; + (async () => { + try { + const story = await loadInkStory('letter-from-the-garden'); + if (cancelled) return; + // Build the slot values from the offline event block + fragment + // corpus (for human-readable titles). buildLetterSlots is pure; + // tested independently in letter-renderer.test.ts. + const slots = buildLetterSlots(block, allFragments); + // Bind variables before the first ChoosePathString. inkjs's + // variablesState getter throws if the variable isn't declared in + // the .ink file — wrap each set in try/catch for resilience. + const vs = story.variablesState as unknown as Record; + try { + vs['plants_bloomed'] = slots.plants_bloomed; + } catch { + /* declared in .ink — should not throw */ + } + try { + vs['fragment_titles'] = slots.fragment_titles; + } catch { + /* declared in .ink — should not throw */ + } + try { + vs['lura_was_here'] = slots.lura_was_here; + } catch { + /* declared in .ink — should not throw */ + } + story.ChoosePathString('letter'); + setRuntime(createInkRuntime(story)); + } catch (err) { + // Fail-soft: log + dismiss. The boot path will already have + // shown the player the live garden behind the overlay; dismissing + // returns them there without losing state. + console.error('[Letter] failed to load', err); + dismissLetter(); + } + })(); + return () => { + cancelled = true; + }; + }, [open, block, dismissLetter]); + + if (!open) return null; + + const onDismiss = (): void => { + // Pitfall 9: synchronous-inside-click audio bootstrap for the + // returning-player path. Do NOT await — bootstrapAudioContext is + // async but the construction MUST happen inside the gesture stack + // frame, not after a microtask boundary. + void bootstrapAudioContext(); + dismissLetter(); + // Also dismiss the Begin gate so a returning player who arrived + // via the letter path doesn't see it again behind the dismissed + // overlay. (D-22 already covers save-existence-based skip; this is + // the belt-and-braces in case the boot path's dismissBeginGate did + // not fire.) + appStore.getState().dismissBeginGate(); + }; + + return ( +
+
e.stopPropagation()} + style={{ + maxWidth: 620, + padding: '3rem 2.6rem', + cursor: 'default', + userSelect: 'text', + }} + > + {runtime ? ( + {}} /> + ) : ( +

...

+ )} + +
+
+ ); +} diff --git a/src/ui/letter/index.ts b/src/ui/letter/index.ts new file mode 100644 index 0000000..10d606d --- /dev/null +++ b/src/ui/letter/index.ts @@ -0,0 +1,12 @@ +/** + * Public barrel for src/ui/letter/. + * + * Plan 02-05 ships: + * - Letter: full-screen DOM overlay rendering letter-from-the-garden.ink + * when a returning player has been away ≥5 minutes (UX-02, D-20). + * - buildLetterSlots: pure helper converting an OfflineEventBlock into + * the Ink template's slot values; separated for testability. + */ +export { Letter } from './Letter'; +export { buildLetterSlots } from './letter-renderer'; +export type { LetterSlots } from './letter-renderer'; diff --git a/src/ui/settings/Settings.test.tsx b/src/ui/settings/Settings.test.tsx new file mode 100644 index 0000000..200b63e --- /dev/null +++ b/src/ui/settings/Settings.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +// Phaser is not loadable under happy-dom — the event-bus import would +// trip checkInverseAlpha. Mock it the same way Plan 02-03's Journal test +// does. +vi.mock('../../game/event-bus', () => ({ + eventBus: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + removeAllListeners: vi.fn(), + }, +})); + +import { Settings } from './Settings'; + +describe('Settings (D-28 — save-management surfaces)', () => { + beforeEach(() => { + // Stub clipboard.writeText to avoid happy-dom permission noise. + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn(async () => {}) }, + configurable: true, + }); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('returns null when open=false', () => { + const { container } = render( {}} />); + expect(container.firstChild).toBeNull(); + }); + + it('mounts the dialog with all four save-management buttons when open', () => { + render( {}} />); + expect(screen.getByRole('dialog', { name: 'Settings' })).toBeTruthy(); + // From content/seasons/01-soil/ui-strings.yaml: + expect(screen.getByText('Save to a copy')).toBeTruthy(); + expect(screen.getByText('Restore from a copy')).toBeTruthy(); + expect(screen.getByText('Earlier garden')).toBeTruthy(); + expect(screen.getByText('Close')).toBeTruthy(); + }); + + it('Close button calls onClose exactly once', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('settings-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('Export button populates the textarea with a non-empty Base64 string', () => { + render( {}} />); + const textarea = screen.getByLabelText('Save data') as HTMLTextAreaElement; + expect(textarea.value).toBe(''); + fireEvent.click(screen.getByTestId('settings-export')); + // Status line surfaces the success copy from inline string. + expect(screen.getByText('Saved to clipboard.')).toBeTruthy(); + // Base64 encoded save round-tripped via wrap + lz-string is non-trivial. + expect(textarea.value.length).toBeGreaterThan(0); + }); + + it('Import on a malformed Base64 input shows the soft-error status', () => { + render( {}} />); + const textarea = screen.getByLabelText('Save data') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'not-a-real-base64-payload' } }); + fireEvent.click(screen.getByTestId('settings-import')); + expect(screen.getByText("That doesn't look like one of yours.")).toBeTruthy(); + }); + + it('Export → Import round-trip keeps the status line at "Restored."', () => { + render( {}} />); + fireEvent.click(screen.getByTestId('settings-export')); + // After Export, the textarea has a real envelope. Importing it should restore. + fireEvent.click(screen.getByTestId('settings-import')); + expect(screen.getByText('Restored.')).toBeTruthy(); + }); +}); diff --git a/src/ui/settings/Settings.tsx b/src/ui/settings/Settings.tsx new file mode 100644 index 0000000..b592764 --- /dev/null +++ b/src/ui/settings/Settings.tsx @@ -0,0 +1,193 @@ +import { useState, type CSSProperties, type JSX } from 'react'; +import { appStore } from '../../store'; +import { + exportToBase64, + importFromBase64, + listSnapshots, + wrap, + unwrap, + migrate, + CURRENT_SCHEMA_VERSION, + buildPayloadFromStore, + hydrateStoreFromPayload, + type V1Payload, +} from '../../save'; +import { uiStrings } from '../../content'; + +/** + * Settings overlay (CONTEXT D-28). Phase 2 ships save-management surfaces + * only — Export to Base64 (CORE-09), Import from Base64 (CORE-09), Restore + * previous snapshot (CORE-08). Audio sliders + keyboard nav + a11y polish + * land in Phase 8 (UX-04, UX-06, UX-07, UX-08). + * + * Per BLOCKER 2 (PLAN W2): the Import path runs the documented pipeline + * `importFromBase64 → unwrap (CRC verify) → migrate`. Skipping unwrap or + * migrate would silently accept any future-shape payload as the current + * shape; this code goes through both. + * + * Player-visible strings come from /content/seasons/01-soil/ui-strings.yaml + * (CLAUDE.md externalized-strings rule). Status-line copy (`'Saved to + * clipboard.'`, `'Restored.'`, etc.) is intentionally inline as a + * Phase-2 minimum-viable choice — Phase 8 moves these into uiStrings if + * tone review demands it. + */ +export function Settings({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}): JSX.Element | null { + const strings = uiStrings[1]?.settings; + const [base64Buf, setBase64Buf] = useState(''); + const [statusLine, setStatusLine] = useState(null); + if (!open || !strings) return null; + + const onExport = (): void => { + try { + const state = appStore.getState(); + // W2 — shared two-arg signature. Settings has no clock injection + // on hand; pass Date.now() for the wall-clock anchor (BLOCKER 3: + // lastTickAt is wall-clock ms, owned by the application layer). + const payload: V1Payload = buildPayloadFromStore(state, Date.now()); + const env = wrap(payload, CURRENT_SCHEMA_VERSION); + const b64 = exportToBase64(env); + void navigator.clipboard?.writeText(b64).catch(() => { + /* clipboard API may be unavailable; the textarea still has the value */ + }); + setBase64Buf(b64); + setStatusLine('Saved to clipboard.'); + } catch { + setStatusLine('Could not save.'); + } + }; + + const onImport = (): void => { + try { + // BLOCKER 2 — full pipeline: importFromBase64 (decompress + Zod + // validate envelope shape) → unwrap (CRC verify; throws + // SaveCorruptError on mismatch) → migrate (chain forward to + // CURRENT_SCHEMA_VERSION). + const env = importFromBase64(base64Buf); + const raw = unwrap(env); + const result = migrate(raw, env.schemaVersion); + hydrateStoreFromPayload(appStore.getState(), result.payload as V1Payload); + setStatusLine('Restored.'); + } catch { + setStatusLine("That doesn't look like one of yours."); + } + }; + + const onRestoreSnapshot = (): void => { + void (async () => { + try { + const snaps = await listSnapshots(); + if (snaps.length === 0) { + setStatusLine('Nothing earlier to find.'); + return; + } + // listSnapshots returns newest-first; restore the most recent. + const latest = snaps[0]; + if (!latest) { + setStatusLine('Nothing earlier to find.'); + return; + } + const raw = unwrap(latest.envelope); + const result = migrate(raw, latest.envelope.schemaVersion); + hydrateStoreFromPayload( + appStore.getState(), + result.payload as V1Payload, + ); + setStatusLine('Earlier garden restored.'); + } catch { + setStatusLine('Nothing earlier could be reached.'); + } + })(); + }; + + return ( +
+
+

+ {strings.title} +

+ +