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.
This commit is contained in:
+317
-11
@@ -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<IRefPhaserGame, IProps>(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<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(() => {
|
||||
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<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;
|
||||
@@ -54,8 +361,7 @@ export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(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 () => {
|
||||
|
||||
Reference in New Issue
Block a user