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; scene: Phaser.Scene | null; } interface IProps { currentActiveScene?: (sceneInstance: Phaser.Scene) => void; } /** 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(() => { 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; } }; }, [ref]); useEffect(() => { const onSceneReady = (scene: Phaser.Scene): void => { sceneRef.current = scene; props.currentActiveScene?.(scene); }; eventBus.on('scene-ready', onSceneReady); // 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. installFirstInteractionGestureHandler(); return () => { eventBus.off('scene-ready', onSceneReady); }; }, [props]); useImperativeHandle(ref, () => ({ game: game.current, scene: sceneRef.current, })); return
; });