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:
+65
-2
@@ -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<IRefPhaserGame | null>(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 (
|
||||
<div id="app">
|
||||
@@ -17,7 +56,31 @@ function App() {
|
||||
<FragmentRevealModal />
|
||||
<JournalIcon />
|
||||
<LuraDialogue />
|
||||
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
|
||||
<Letter />
|
||||
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<PersistenceToast show={showPersistenceToast} />
|
||||
<button
|
||||
data-testid="settings-icon"
|
||||
aria-label="Open settings"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 76,
|
||||
zIndex: 40,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
background: '#2a2a2e',
|
||||
color: '#e8e0d0',
|
||||
border: '1px solid #4d4d52',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'serif',
|
||||
fontSize: '1.2rem',
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+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 () => {
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<SessionSlice, [], [], SessionSlice> = (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 }),
|
||||
});
|
||||
|
||||
@@ -9,3 +9,5 @@ export * from './begin';
|
||||
export * from './garden';
|
||||
export * from './journal';
|
||||
export * from './dialogue';
|
||||
export * from './letter';
|
||||
export * from './settings';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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(<Letter />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts the dialog when letterOverlayOpen=true (loading state, null block)', () => {
|
||||
appStore.setState({
|
||||
letterOverlayOpen: true,
|
||||
pendingLetterEventBlock: null,
|
||||
});
|
||||
render(<Letter />);
|
||||
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(<Letter />);
|
||||
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(<Letter />);
|
||||
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(<Letter />);
|
||||
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(<Letter />);
|
||||
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(<Letter />);
|
||||
await waitFor(() => {
|
||||
expect(loadInkStory).toHaveBeenCalledWith('letter-from-the-garden');
|
||||
});
|
||||
expect(fakeStoryProto.ChoosePathString).toHaveBeenCalledWith('letter');
|
||||
});
|
||||
});
|
||||
@@ -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<InkRuntime | null>(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<string, unknown>;
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="A letter from the garden"
|
||||
data-testid="letter-overlay"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 95,
|
||||
background: '#0c0c0d',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: '#e8e0d0',
|
||||
fontFamily: 'serif',
|
||||
}}
|
||||
>
|
||||
<article
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: 620,
|
||||
padding: '3rem 2.6rem',
|
||||
cursor: 'default',
|
||||
userSelect: 'text',
|
||||
}}
|
||||
>
|
||||
{runtime ? (
|
||||
<InkRenderer runtime={runtime} onComplete={() => {}} />
|
||||
) : (
|
||||
<p style={{ opacity: 0.4 }}>...</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
marginTop: '2rem',
|
||||
padding: '0.5rem 1.6rem',
|
||||
background: 'transparent',
|
||||
color: '#e8e0d0',
|
||||
border: '1px solid #e8e0d0',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'serif',
|
||||
}}
|
||||
>
|
||||
Tend the garden
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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(<Settings open={false} onClose={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts the dialog with all four save-management buttons when open', () => {
|
||||
render(<Settings open={true} onClose={() => {}} />);
|
||||
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(<Settings open={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('settings-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Export button populates the textarea with a non-empty Base64 string', () => {
|
||||
render(<Settings open={true} onClose={() => {}} />);
|
||||
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(<Settings open={true} onClose={() => {}} />);
|
||||
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(<Settings open={true} onClose={() => {}} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={strings.title}
|
||||
data-testid="settings-modal"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: '#1a1a1ac0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#e8e0d0',
|
||||
fontFamily: 'serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 520,
|
||||
background: '#1f1f23',
|
||||
padding: '2rem',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
marginTop: 0,
|
||||
fontWeight: 300,
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
{strings.title}
|
||||
</h2>
|
||||
<button data-testid="settings-export" onClick={onExport} style={btnStyle}>
|
||||
{strings.export}
|
||||
</button>
|
||||
<textarea
|
||||
value={base64Buf}
|
||||
onChange={(e) => setBase64Buf(e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '1rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
aria-label="Save data"
|
||||
/>
|
||||
<button data-testid="settings-import" onClick={onImport} style={btnStyle}>
|
||||
{strings.import}
|
||||
</button>
|
||||
<button
|
||||
data-testid="settings-restore"
|
||||
onClick={onRestoreSnapshot}
|
||||
style={btnStyle}
|
||||
>
|
||||
{strings.restore_snapshot}
|
||||
</button>
|
||||
{statusLine && (
|
||||
<p style={{ opacity: 0.6, fontStyle: 'italic' }}>{statusLine}</p>
|
||||
)}
|
||||
<button
|
||||
data-testid="settings-close"
|
||||
onClick={onClose}
|
||||
style={{ ...btnStyle, marginTop: '1.5rem' }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnStyle: CSSProperties = {
|
||||
display: 'block',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'transparent',
|
||||
color: '#e8e0d0',
|
||||
border: '1px solid #4d4d52',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'serif',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Public barrel for src/ui/settings/.
|
||||
*
|
||||
* Plan 02-05 ships:
|
||||
* - Settings: D-28 save-management modal (Export / Import / Restore)
|
||||
* - PersistenceToast: D-30 one-time soft toast in voice
|
||||
*/
|
||||
export { Settings } from './Settings';
|
||||
export { PersistenceToast } from './persistence-toast';
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState, type JSX } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { uiStrings } from '../../content';
|
||||
|
||||
/**
|
||||
* Persistence-result toast (CONTEXT D-30 + UX-13).
|
||||
*
|
||||
* One-time soft toast in voice on first save if `navigator.storage.persist()`
|
||||
* was denied; nothing if granted. State remembered via
|
||||
* `settings.persistenceToastShown` so the toast only fires once across
|
||||
* sessions.
|
||||
*
|
||||
* Triggered by src/PhaserGame.tsx after the boot path's requestPersistence
|
||||
* call resolves with `granted=false && apiAvailable=true && !persistenceToastShown`.
|
||||
*
|
||||
* Anti-FOMO compliant — the copy is in the gardener-keeper voice
|
||||
* (uiStrings[1].settings.persistence_denied_toast: "The garden may forget,
|
||||
* if your browser asks it to."). No nag, no streak, no daily-login pressure.
|
||||
*/
|
||||
const TOAST_DURATION_MS = 6500;
|
||||
|
||||
export function PersistenceToast({ show }: { show: boolean }): JSX.Element | null {
|
||||
const [visible, setVisible] = useState(show);
|
||||
const setShown = useAppStore((s) => s.setPersistenceToastShown);
|
||||
const strings = uiStrings[1]?.settings;
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
const t = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setShown(true);
|
||||
}, TOAST_DURATION_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [show, setShown]);
|
||||
|
||||
if (!visible || !strings) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="persistence-toast"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
zIndex: 30,
|
||||
maxWidth: 420,
|
||||
padding: '0.8rem 1.2rem',
|
||||
background: '#1f1f23ee',
|
||||
color: '#e8e0d0',
|
||||
border: '1px solid #4d4d52',
|
||||
fontFamily: 'serif',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{strings.persistence_denied_toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user