5d58d6cc7b
- 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.
379 lines
13 KiB
TypeScript
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" />;
|
|
});
|