Files
TheLastGarden/src/PhaserGame.tsx
T
josh 5d58d6cc7b feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection
- 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.
2026-05-09 10:57:09 -04:00

379 lines
13 KiB
TypeScript

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<IRefPhaserGame, IProps>(function PhaserGame(
props,
ref,
) {
const game = useRef<Phaser.Game | null>(null);
const sceneRef = useRef<Phaser.Scene | null>(null);
// W5 — lifecycle handle held in a ref so the OUTER cleanup can detach.
const lifecycleRef = useRef<LifecycleHooksHandle | null>(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<ReturnType<typeof openSaveDB>> | 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<void> {
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<ReturnType<typeof openSaveDB>>,
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 <div id="game-container" />;
});