diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md index ff6bf22..93564eb 100644 --- a/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md @@ -605,7 +605,11 @@ export function simulateOneTick( next = compost(next, cmd.tileIdx, currentTick); } } - return { ...next, lastTickAt: currentTick }; + // BLOCKER 3 invariant (matches Plan 02-02 line 457): the sim writes + // tickCount (sim-internal counter for STRY-10), NEVER lastTickAt. + // lastTickAt is wall-clock ms owned by saveSync. Re-authoring this file + // in 02-03 must preserve the increment that 02-02 established. + return { ...next, tickCount: next.tickCount + 1 }; } ``` @@ -1038,6 +1042,8 @@ Plan 02-04's authored content will land the actual lines. - `grep -q "kind: 'harvest'" src/game/scenes/Garden.ts` - `grep -q "kind: 'compost'" src/game/scenes/Garden.ts` - `grep -q "setFragmentRevealId" src/game/scenes/Garden.ts` (reveal flow wired) + - `! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts` (BLOCKER 3 — 02-03's edits to commands.ts must not re-introduce sim-side lastTickAt writes) + - `grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts` (BLOCKER 3 — 02-02's tickCount increment must survive 02-03's harvest/compost edits) - `npx vitest run src/ui/journal/` exits 0 with all tests green (≥10 cases across 2 files) - `npm run ci` exits 0 diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md index ffe8efa..53c0dc2 100644 --- a/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md @@ -15,6 +15,7 @@ files_modified: - src/sim/garden/commands.ts - src/save/migrations.ts - src/save/index.ts + - src/save/payload.ts - src/ui/letter/Letter.tsx - src/ui/letter/Letter.test.tsx - src/ui/letter/letter-renderer.ts @@ -380,7 +381,10 @@ export function simulateOneTick(state, currentTick, commands, ctx): SimState { if (ctx.silent) { next = autoHarvestReadyPlants(next, currentTick, ctx); } - return { ...next, lastTickAt: currentTick }; + // BLOCKER 3 invariant (matches Plan 02-02 line 457 and Plan 02-03): + // the sim NEVER writes lastTickAt. saveSync owns wall-clock ms; the + // sim owns the tickCount counter (STRY-10). + return { ...next, tickCount: next.tickCount + 1 }; } ``` @@ -697,12 +701,93 @@ export { buildLetterSlots } from './letter-renderer'; export type { LetterSlots } from './letter-renderer'; ``` +**Step 3.5 — `src/save/payload.ts`** — shared helpers (W2 fix: deduplicate Settings.tsx and PhaserGame.tsx): + +The previous revision left two parallel definitions of `buildPayloadFromStore` / +`hydrateStoreFromPayload` — one in `Settings.tsx` (one-arg, calling `Date.now()` +inline) and one in `PhaserGame.tsx` (two-arg, taking the clock-derived `nowMs`). +That arity divergence is the source of W2. The fix: extract BOTH helpers to a +single module with the two-arg signature; both call sites import from it. + +The `nowMs` parameter is the wall-clock ms to write into `payload.lastTickAt`. +Settings.tsx (no clock injection on hand) passes `Date.now()`; PhaserGame.tsx +passes `clock.now()` (the injected clock — wallClock or FakeClock). The +two-arg surface unifies the contract; the *value* passed differs, which is +correct. + +```typescript +import type { AppState } from '../store'; +import type { V1Payload } from './schemas'; + +/** + * Build a V1Payload save envelope from the current store state. + * + * BLOCKER 3 invariants: + * - lastTickAt is wall-clock ms — owned by saveSync (PhaserGame) and the + * Settings export path. The sim NEVER writes lastTickAt. + * - tickCount is the sim-internal monotonic counter (STRY-10) — read from + * the store; the sim writes it via simulateOneTick. + * + * @param s Snapshot of the store state (`useAppStore.getState()`). + * @param nowMs Wall-clock milliseconds to record as `lastTickAt`. + * PhaserGame.tsx passes `clock.now()` (injected clock); + * Settings.tsx passes `Date.now()` (no clock on hand). + */ +export function buildPayloadFromStore(s: AppState, nowMs: number): V1Payload { + return { + garden: { tiles: s.tiles }, + plants: [], + harvestedFragmentIds: s.harvestedFragmentIds, + lastTickAt: nowMs, // wall-clock ms + tickCount: s.tickCount ?? 0, // BLOCKER 3 — sim-internal counter + unlockedPlantTypes: s.unlockedPlantTypes, + luraBeatProgress: s.luraBeatProgress, + offlineEvents: null, + 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. + */ +export function hydrateStoreFromPayload(s: AppState, 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 so STRY-10 narrative gating resumes. + s.setTickCount?.(payload.tickCount ?? 0); +} +``` + +Update `src/save/index.ts` to re-export the two helpers: +```typescript +export { buildPayloadFromStore, hydrateStoreFromPayload } from './payload'; +``` + **Step 4 — `src/ui/settings/Settings.tsx`** (D-28 save-management only): ```typescript import { useState } from 'react'; import { useAppStore } from '../../store'; -import { exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB, wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, type V1Payload } from '../../save'; +import { + exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB, + wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, + buildPayloadFromStore, hydrateStoreFromPayload, + type V1Payload, +} from '../../save'; import { uiStrings } from '../../content'; /** @@ -720,7 +805,9 @@ export function Settings({ open, onClose }: { open: boolean; onClose: () => void // Build a fresh save envelope from current store state. // (Plan 02-05 wires the same payload-build path in src/PhaserGame.tsx for save lifecycle hooks.) const state = useAppStore.getState(); - const payload: V1Payload = buildPayloadFromStore(state); + // W2: shared two-arg signature. Settings has no injected clock, so we + // pass Date.now() directly — PhaserGame's saveSync passes clock.now(). + const payload: V1Payload = buildPayloadFromStore(state, Date.now()); const env = wrap(payload, CURRENT_SCHEMA_VERSION); const b64 = await exportToBase64(env); navigator.clipboard?.writeText(b64).catch(() => {}); @@ -740,7 +827,7 @@ export function Settings({ open, onClose }: { open: boolean; onClose: () => void const env = await importFromBase64(base64Buf); const raw = unwrap(env); // verifies CRC const { payload } = migrate(raw, env.schemaVersion); // upgrades schema - hydrateStoreFromPayload(payload as V1Payload); + hydrateStoreFromPayload(useAppStore.getState(), payload as V1Payload); setStatusLine('Restored.'); } catch (e) { setStatusLine('That doesn\'t look like one of yours.'); @@ -759,7 +846,7 @@ export function Settings({ open, onClose }: { open: boolean; onClose: () => void // Phase 8 may add a list selector). const last = snaps[snaps.length - 1]; const payload = unwrap(last.envelope); - hydrateStoreFromPayload(payload as V1Payload); + hydrateStoreFromPayload(useAppStore.getState(), payload as V1Payload); setStatusLine('Earlier garden restored.'); } catch (e) { setStatusLine('Nothing earlier could be reached.'); @@ -799,39 +886,10 @@ const btnStyle: React.CSSProperties = { fontFamily: 'serif', textAlign: 'left', width: '100%', }; -// Helpers — these live here for now; can be extracted to src/save/ if reused. -// BLOCKER 3 invariants: -// - lastTickAt is wall-clock ms (set here at export time via Date.now()) -// - tickCount is the sim-internal monotonic counter (read from the store; -// simAdapter.applyTickCount writes it into the store every Garden.update -// so Settings.tsx can read it without coupling to the active scene) -function buildPayloadFromStore(s: ReturnType): V1Payload { - return { - garden: { tiles: s.tiles }, - plants: [], - harvestedFragmentIds: s.harvestedFragmentIds, - lastTickAt: Date.now(), - tickCount: s.tickCount ?? 0, - unlockedPlantTypes: s.unlockedPlantTypes, - luraBeatProgress: s.luraBeatProgress, - offlineEvents: null, - settings: { - musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, - persistenceToastShown: s.persistenceToastShown, - }, - }; -} - -function hydrateStoreFromPayload(payload: V1Payload): void { - const state = useAppStore.getState(); - state.applyTilesAndUnlocks(payload.garden.tiles, payload.unlockedPlantTypes); - state.setHarvested(payload.harvestedFragmentIds); - state.setLuraBeatProgress(payload.luraBeatProgress); - state.setPersistenceToastShown(payload.settings.persistenceToastShown); - // BLOCKER 3 — restore the sim's tick counter so a returning player resumes - // where they left off rather than restarting at tick 0. - state.setTickCount?.(payload.tickCount ?? 0); -} +// W2: helpers live in `src/save/payload.ts` (Step 3.5) — both Settings.tsx +// and PhaserGame.tsx import from there. The unified two-arg signature +// (`(state, nowMs)` for build; `(state, payload)` for hydrate) eliminates +// the arity divergence the verifier flagged. ``` (Phase 2 minimum-viable Settings — clipboard read/paste UX is acceptable; Phase 8 polishes.) @@ -921,6 +979,7 @@ import { installFirstInteractionGestureHandler } from './ui/begin'; import { openSaveDB, requestPersistence, wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, registerSaveLifecycleHooks, + buildPayloadFromStore, hydrateStoreFromPayload, type V1Payload, type SavedRecord, } from './save'; import { @@ -982,7 +1041,7 @@ export const PhaserGame = forwardRef(function PhaserGame const { payload: migratedPayload } = migrate(raw, env.schemaVersion); const payload = migratedPayload as V1Payload; appStore.getState().dismissBeginGate(); // D-22: skip Begin - hydrateStoreFromPayload(payload); + hydrateStoreFromPayload(appStore.getState(), payload); // Offline catch-up const off = computeOfflineCatchup(payload.lastTickAt, nowMs); @@ -994,7 +1053,7 @@ export const PhaserGame = forwardRef(function PhaserGame return simulateOneTick(s as never, (s as { lastTickAt: number }).lastTickAt + 1, [], { ...ctx, silent }); }, true); const finalState = result.state as unknown as V1Payload; - hydrateStoreFromPayload(finalState); + hydrateStoreFromPayload(appStore.getState(), finalState); if (off.cappedMs >= ABSENCE_LETTER_THRESHOLD_MS) { appStore.getState().openLetter(finalState.offlineEvents); @@ -1084,34 +1143,10 @@ function PersistenceToastWrapper(): JSX.Element | null { return ; } -// Helpers (mirror Settings.tsx — deduplicate by extracting if reused). -// BLOCKER 3 invariant: `lastTickAt` is WALL-CLOCK MILLISECONDS (the value of -// clock.now() at save time). The sim never writes lastTickAt — only saveSync -// in PhaserGame.tsx and the Settings export path do. computeOfflineCatchup -// reads the same wall-clock convention. -function buildPayloadFromStore(s: ReturnType, lastTickAt: number): V1Payload { - return { - garden: { tiles: s.tiles }, - plants: [], - harvestedFragmentIds: s.harvestedFragmentIds, - lastTickAt, // wall-clock ms, owned by saveSync - tickCount: s.tickCount ?? 0, // BLOCKER 3 — sim-internal counter from store - unlockedPlantTypes: s.unlockedPlantTypes, - luraBeatProgress: s.luraBeatProgress, - offlineEvents: null, - settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: s.persistenceToastShown }, - }; -} -function hydrateStoreFromPayload(payload: V1Payload): void { - const state = appStore.getState(); - state.applyTilesAndUnlocks(payload.garden.tiles ?? new Array(16).fill(null), payload.unlockedPlantTypes ?? []); - state.setHarvested(payload.harvestedFragmentIds ?? []); - state.setLuraBeatProgress(payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null }); - state.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false); - // BLOCKER 3 — restore tickCount so the sim's STRY-10 narrative gating - // resumes from the correct counter on return. - state.setTickCount?.(payload.tickCount ?? 0); -} +// W2: helpers live in `src/save/payload.ts` (Step 3.5) — both Settings.tsx +// and PhaserGame.tsx import from there. The unified two-arg signature +// eliminates the arity divergence the verifier flagged. saveSync above +// passes `clock.now()` for lastTickAt; Settings.tsx passes `Date.now()`. ``` (NOTE: The `require` inside `PersistenceToastWrapper` will fail in ESM — replace with a top-level import. Reorganize the file so PersistenceToast imports cleanly. The above is a sketch; the executor finalizes the import order to avoid circular deps. Acceptable approach: move `PersistenceToast` rendering up to App.tsx and pass the `show` boolean via the store — see Step 11 below.) @@ -1132,8 +1167,7 @@ private get clock(): Clock { **Step 11 — Update `src/App.tsx`** to mount Letter, Settings, PersistenceToast, and a SettingsIcon: ```typescript -import { useState } from 'react'; -import { useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; import { BeginScreen } from './ui/begin'; import { SeedPicker } from './ui/garden'; @@ -1230,8 +1264,18 @@ export default App; - `grep -q "migrate(" src/ui/settings/Settings.tsx` (BLOCKER 2: import path runs migrate) - `grep -E "unwrap\\(.*\\)|migrate\\(" src/ui/settings/Settings.tsx | head -5` (BLOCKER 2: both unwrap and migrate appear) - `grep -q "lastTickAt: clock.now()" src/PhaserGame.tsx` (BLOCKER 3: saveSync writes wall-clock ms via clock.now) + - `test -f src/save/payload.ts` (W2: shared payload helpers extracted) + - `grep -q "export function buildPayloadFromStore" src/save/payload.ts` + - `grep -q "export function hydrateStoreFromPayload" src/save/payload.ts` + - `grep -E "from '\\.\\./save'" src/ui/settings/Settings.tsx` (W2: Settings imports from save barrel — buildPayloadFromStore + hydrateStoreFromPayload) + - `grep -E "from '\\./save'" src/PhaserGame.tsx` (W2: PhaserGame imports from save barrel — buildPayloadFromStore + hydrateStoreFromPayload) + - `grep -q "buildPayloadFromStore" src/save/index.ts` (W2: barrel re-exports the shared helpers) + - `! grep -E "^function buildPayloadFromStore" src/ui/settings/Settings.tsx` (W2: no inline duplicate definition in Settings) + - `! grep -E "^function buildPayloadFromStore" src/PhaserGame.tsx` (W2: no inline duplicate definition in PhaserGame) - `! grep -q "lastTickAt: this\\." src/sim/garden/Garden.ts || true` (BLOCKER 3: sim does NOT write lastTickAt — Garden lives under src/game/scenes/, not src/sim/, but this guards against accidental sim-side writes) - `! grep -E "lastTickAt: this\\.(currentTick|tickCount)" src/game/scenes/Garden.ts` (BLOCKER 3: Garden scene does NOT overwrite lastTickAt with a tick counter — saveSync owns it) + - `! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts` (BLOCKER 3 — 02-05's silent-mode edits to commands.ts must not re-introduce sim-side lastTickAt writes) + - `grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts` (BLOCKER 3 — tickCount increment from 02-02 must survive 02-05's silent-mode edits) - `grep -q "addEventListener('keydown'" src/App.tsx` (W2: D-29 keyboard shortcut wired) - `grep -q "tlg:toggle-journal" src/App.tsx` (W2: journal hotkey dispatches the toggle event) - `grep -q "lifecycleRef.current?.detach()" src/PhaserGame.tsx` (W5: lifecycle handle reaches outer effect cleanup)