revise(02): BLOCKER 3 — split lastTickAt (wall-clock) from tickCount (sim counter)

Two distinct fields with strict separation:
  - lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
    the application layer. The sim NEVER writes this field.
    computeOfflineCatchup uses it as the wall-clock anchor.
  - tickCount: monotonic sim-internal counter (one per simulate() call).
    Used for STRY-10 narrative gating that must be immune to wall-clock
    manipulation. The sim writes this field; the application layer reads
    it via simAdapter.applyTickCount.

Changes:
  02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
  defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
  simAdapter exposes applyTickCount; tests assert the round-trip.
  02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
  currentTick); Garden scene's SimState snapshot reads lastTickAt
  through from store and writes tickCount: this.currentTick locally;
  acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
  02-05: buildPayloadFromStore now persists tickCount (from store);
  hydrateStoreFromPayload restores it via state.setTickCount.

This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
This commit is contained in:
2026-05-09 03:04:45 -04:00
parent a9f190ed27
commit e5c55b0aae
3 changed files with 85 additions and 10 deletions
@@ -52,8 +52,9 @@ must_haves:
- "drainTicks at TICK_MS=200ms over 24h completes ≤500ms on a modern machine (Vitest benchmark)" - "drainTicks at TICK_MS=200ms over 24h completes ≤500ms on a modern machine (Vitest benchmark)"
- "Zustand 5 vanilla createStore composes 4 slices (garden / memory / narrative / session); useAppStore hook re-renders on selector changes; getState() works without React" - "Zustand 5 vanilla createStore composes 4 slices (garden / memory / narrative / session); useAppStore hook re-renders on selector changes; getState() works without React"
- "simAdapter (in src/store/) exposes applySimResult(next, events) and drainCommands(); src/sim/ never imports src/store/" - "simAdapter (in src/store/) exposes applySimResult(next, events) and drainCommands(); src/sim/ never imports src/store/"
- "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays at 1; no migrations[2] entry exists" - "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown, AND tickCount (BLOCKER 3 — sim-internal monotonic counter, separate from lastTickAt) — CURRENT_SCHEMA_VERSION stays at 1; no migrations[2] entry exists"
- "migrations[1] (the v0→v1 demo) returns a fully-populated V1Payload including all new fields with sensible defaults" - "migrations[1] (the v0→v1 demo) returns a fully-populated V1Payload including all new fields (tickCount: 0) with sensible defaults"
- "BLOCKER 3 invariant: SimState.lastTickAt is wall-clock milliseconds (written ONLY at saveSync time by the application layer); SimState.tickCount is the sim-internal monotonic counter (incremented inside simulateOneTick). The sim never writes lastTickAt."
- "save lifecycle hook fires synchronously on visibilitychange→hidden, on beforeunload, AND on saveOnSeasonTransition() invocation (UX-10); Vitest exercises all three triggers" - "save lifecycle hook fires synchronously on visibilitychange→hidden, on beforeunload, AND on saveOnSeasonTransition() invocation (UX-10); Vitest exercises all three triggers"
- "Phaser EventBus singleton (src/game/event-bus.ts) exports `eventBus = new Phaser.Events.EventEmitter()` per Phaser 4 official template" - "Phaser EventBus singleton (src/game/event-bus.ts) exports `eventBus = new Phaser.Events.EventEmitter()` per Phaser 4 official template"
- "ESLint extension: any new src/sim/** file calling Date.now() (except src/sim/scheduler/clock.ts) fails lint with rule id 'no-restricted-syntax'; deliberate-violation fixture proves it" - "ESLint extension: any new src/sim/** file calling Date.now() (except src/sim/scheduler/clock.ts) fails lint with rule id 'no-restricted-syntax'; deliberate-violation fixture proves it"
@@ -106,7 +107,7 @@ must_haves:
- from: src/save/migrations.ts - from: src/save/migrations.ts
to: "extended V1Payload" to: "extended V1Payload"
via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown" via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown"
pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown" pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown|tickCount"
- from: eslint.config.js - from: eslint.config.js
to: src/sim/scheduler/clock.ts to: src/sim/scheduler/clock.ts
via: "no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/**" via: "no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/**"
@@ -458,12 +459,24 @@ export type { OfflineCatchupSpec } from './catchup';
* *
* Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02) * Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02)
* fleshes them out with real interfaces in src/sim/garden/types.ts. * fleshes them out with real interfaces in src/sim/garden/types.ts.
*
* BLOCKER 3 invariant — two distinct time fields with strict separation:
* - lastTickAt: wall-clock milliseconds. Written ONLY by the application
* layer at saveSync time (src/PhaserGame.tsx). The sim NEVER writes
* this field. computeOfflineCatchup reads it as wall-clock ms.
* - tickCount: monotonically-increasing sim-internal counter (one per
* simulate() call). Used for STRY-10 narrative gating that must be
* immune to wall-clock manipulation. The sim DOES write this field.
* The application layer reads it but never writes it.
*/ */
export interface SimState { export interface SimState {
garden: { tiles: unknown[] }; garden: { tiles: unknown[] };
plants: unknown[]; plants: unknown[];
harvestedFragmentIds: string[]; harvestedFragmentIds: string[];
/** Wall-clock milliseconds at last save. Written ONLY at saveSync. */
lastTickAt: number; lastTickAt: number;
/** Monotonic sim tick counter. Incremented by the sim; used for STRY-10. */
tickCount: number;
unlockedPlantTypes: string[]; unlockedPlantTypes: string[];
luraBeatProgress: { luraBeatProgress: {
arrived: boolean; arrived: boolean;
@@ -557,9 +570,21 @@ export interface V1Payload {
garden: { tiles: unknown[] }; garden: { tiles: unknown[] };
plants: unknown[]; plants: unknown[];
harvestedFragmentIds: string[]; harvestedFragmentIds: string[];
/**
* Wall-clock milliseconds at last save. Per BLOCKER 3 invariant:
* written ONLY at saveSync time by src/PhaserGame.tsx; the sim never
* writes this. computeOfflineCatchup uses it as the wall-clock anchor.
*/
lastTickAt: number; lastTickAt: number;
// NEW Phase 2 fields: // NEW Phase 2 fields:
/**
* Monotonic sim tick counter. Incremented inside simulateOneTick.
* Used by STRY-10 narrative gating so beats remain immune to system-
* clock manipulation. Persisted so a returning player resumes at the
* correct tick count rather than restarting at zero.
*/
tickCount: number;
unlockedPlantTypes: string[]; unlockedPlantTypes: string[];
luraBeatProgress: { luraBeatProgress: {
arrived: boolean; arrived: boolean;
@@ -601,6 +626,7 @@ Update `migrations[1]` body to populate the new defaults:
plants: [], plants: [],
harvestedFragmentIds: [], harvestedFragmentIds: [],
lastTickAt: Date.now(), lastTickAt: Date.now(),
tickCount: 0, // BLOCKER 3 — fresh sim starts at tick 0
unlockedPlantTypes: [], unlockedPlantTypes: [],
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
offlineEvents: null, offlineEvents: null,
@@ -624,6 +650,7 @@ Existing v0→v1 test still asserts the migration runs; ADD assertions for new f
- `migrations[1]({...}).offlineEvents` is `null`. - `migrations[1]({...}).offlineEvents` is `null`.
- `migrations[1]({...}).settings.persistenceToastShown` is `false`. - `migrations[1]({...}).settings.persistenceToastShown` is `false`.
- `migrations[1]({...}).settings.musicVolume` is `0.7` (existing value preserved). - `migrations[1]({...}).settings.musicVolume` is `0.7` (existing value preserved).
- `migrations[1]({...}).tickCount` is `0` (BLOCKER 3 — sim-internal counter starts fresh).
Also add a regression-defense test: `expect(Object.keys(migrations).sort()).toEqual(['1'])` — proves no `migrations[2]` was sneakily added. Also add a regression-defense test: `expect(Object.keys(migrations).sort()).toEqual(['1'])` — proves no `migrations[2]` was sneakily added.
@@ -652,15 +679,25 @@ export interface GardenCommand {
export interface GardenSlice { export interface GardenSlice {
tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface
unlockedPlantTypes: string[]; unlockedPlantTypes: string[];
/** BLOCKER 3 — sim-internal monotonic counter; written by simAdapter.applyTickCount. */
tickCount: number;
/** BLOCKER 3 — wall-clock ms at last save; read-through from migrated payload. */
lastTickAt: number;
pendingCommands: GardenCommand[]; pendingCommands: GardenCommand[];
enqueueCommand: (cmd: GardenCommand) => void; enqueueCommand: (cmd: GardenCommand) => void;
drainCommands: () => GardenCommand[]; drainCommands: () => GardenCommand[];
applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void; applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void;
/** BLOCKER 3 — write the sim-internal counter into the store. */
setTickCount: (n: number) => void;
/** BLOCKER 3 — write wall-clock ms (used by saveSync's payload build path). */
setLastTickAt: (ms: number) => void;
} }
export const createGardenSlice: StateCreator<GardenSlice, [], [], GardenSlice> = (set, get) => ({ export const createGardenSlice: StateCreator<GardenSlice, [], [], GardenSlice> = (set, get) => ({
tiles: new Array(16).fill(null), tiles: new Array(16).fill(null),
unlockedPlantTypes: [], unlockedPlantTypes: [],
tickCount: 0,
lastTickAt: 0,
pendingCommands: [], pendingCommands: [],
enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })), enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })),
drainCommands: () => { drainCommands: () => {
@@ -669,6 +706,8 @@ export const createGardenSlice: StateCreator<GardenSlice, [], [], GardenSlice> =
return cmds; return cmds;
}, },
applyTilesAndUnlocks: (tiles, unlocked) => set({ tiles, unlockedPlantTypes: unlocked }), applyTilesAndUnlocks: (tiles, unlocked) => set({ tiles, unlockedPlantTypes: unlocked }),
setTickCount: (n) => set({ tickCount: n }),
setLastTickAt: (ms) => set({ lastTickAt: ms }),
}); });
``` ```
@@ -791,6 +830,10 @@ export const simAdapter = {
applyLuraProgress(p: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null }): void { applyLuraProgress(p: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null }): void {
appStore.getState().setLuraBeatProgress(p); appStore.getState().setLuraBeatProgress(p);
}, },
/** BLOCKER 3 — flow the sim's tickCount into the store so saveSync can read it. */
applyTickCount(n: number): void {
appStore.getState().setTickCount(n);
},
}; };
``` ```
@@ -817,8 +860,9 @@ export * from './selectors';
``` ```
**Step 4 — `src/store/store.test.ts`** — Vitest: **Step 4 — `src/store/store.test.ts`** — Vitest:
- Slice composition: `appStore.getState()` has all four slice keys (`pendingCommands`, `harvestedFragmentIds`, `luraBeatProgress`, `beginGateDismissed`). - Slice composition: `appStore.getState()` has all four slice keys (`pendingCommands`, `harvestedFragmentIds`, `luraBeatProgress`, `beginGateDismissed`, `tickCount`, `lastTickAt`).
- Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`. - Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`.
- BLOCKER 3 round-trip: `setTickCount(7)` updates state.tickCount to 7; `setLastTickAt(1234567)` updates state.lastTickAt to 1234567; both fields default to 0.
- React hook surface: `renderHook(() => useAppStore(s => s.harvestedFragmentIds.length))` from `@testing-library/react` re-renders when `setHarvested(['season1.soil.x'])` fires. NOTE: `@testing-library/react` is NOT installed yet — install it as a devDep before writing this part of the test (`npm install -D @testing-library/react`). Confirm `package.json` reflects the install. - React hook surface: `renderHook(() => useAppStore(s => s.harvestedFragmentIds.length))` from `@testing-library/react` re-renders when `setHarvested(['season1.soil.x'])` fires. NOTE: `@testing-library/react` is NOT installed yet — install it as a devDep before writing this part of the test (`npm install -D @testing-library/react`). Confirm `package.json` reflects the install.
- Selector check: `selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})` returns `true`. - Selector check: `selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})` returns `true`.
@@ -928,11 +972,16 @@ export const eventBus = new Phaser.Events.EventEmitter();
- `grep -q "OfflineEventBlock" src/save/migrations.ts` (new field type declared inline) - `grep -q "OfflineEventBlock" src/save/migrations.ts` (new field type declared inline)
- `grep -q "luraBeatProgress" src/save/migrations.ts` - `grep -q "luraBeatProgress" src/save/migrations.ts`
- `grep -q "persistenceToastShown" src/save/migrations.ts` - `grep -q "persistenceToastShown" src/save/migrations.ts`
- `grep -q "tickCount" src/save/migrations.ts` (BLOCKER 3 — sim-internal counter declared)
- `grep -q "tickCount: 0" src/save/migrations.ts` (BLOCKER 3 — fresh-game default)
- `grep -c "^ [0-9]:" src/save/migrations.ts` reports `1` exactly (only `migrations[1]`; no `migrations[2]`) - `grep -c "^ [0-9]:" src/save/migrations.ts` reports `1` exactly (only `migrations[1]`; no `migrations[2]`)
- `grep -q "CURRENT_SCHEMA_VERSION = 1" src/save/migrations.ts` (version stays 1) - `grep -q "CURRENT_SCHEMA_VERSION = 1" src/save/migrations.ts` (version stays 1)
- `grep -q "import { createStore } from 'zustand/vanilla'" src/store/store.ts` - `grep -q "import { createStore } from 'zustand/vanilla'" src/store/store.ts`
- `grep -q "export const appStore" src/store/store.ts` - `grep -q "export const appStore" src/store/store.ts`
- `grep -q "export const simAdapter" src/store/sim-adapter.ts` - `grep -q "export const simAdapter" src/store/sim-adapter.ts`
- `grep -q "tickCount" src/store/garden-slice.ts` (BLOCKER 3 — slice owns tickCount)
- `grep -q "setTickCount" src/store/garden-slice.ts`
- `grep -q "applyTickCount" src/store/sim-adapter.ts` (BLOCKER 3 — sim → store flow path)
- `grep -q "registerSaveLifecycleHooks" src/save/lifecycle.ts` - `grep -q "registerSaveLifecycleHooks" src/save/lifecycle.ts`
- `grep -q "saveOnSeasonTransition" src/save/lifecycle.ts` - `grep -q "saveOnSeasonTransition" src/save/lifecycle.ts`
- `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added) - `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added)
@@ -451,7 +451,10 @@ export function simulateOneTick(state: SimState, currentTick: number, commands:
} }
// Plan 02-03 will add 'harvest' and 'compost' branches here. // Plan 02-03 will add 'harvest' and 'compost' branches here.
} }
return { ...next, lastTickAt: currentTick }; // BLOCKER 3 invariant: the sim writes tickCount (sim-internal counter for
// STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the
// application layer's saveSync (src/PhaserGame.tsx).
return { ...next, tickCount: next.tickCount + 1 };
} }
/** /**
@@ -477,8 +480,9 @@ import type { GrowthStage } from './types';
- `plantSeed(state, 0, 'yarrow', 100)` with `unlockedPlantTypes=['rosemary']` (yarrow locked) returns state unchanged. - `plantSeed(state, 0, 'yarrow', 100)` with `unlockedPlantTypes=['rosemary']` (yarrow locked) returns state unchanged.
- `plantSeed(state, 0, 'rosemary', 100)` then `plantSeed(state', 0, 'rosemary', 200)` — second call returns state' unchanged (tile occupied; silent no-op). - `plantSeed(state, 0, 'rosemary', 100)` then `plantSeed(state', 0, 'rosemary', 200)` — second call returns state' unchanged (tile occupied; silent no-op).
- `plantSeed(state, 16, ...)` throws (out-of-range tileIdx). - `plantSeed(state, 16, ...)` throws (out-of-range tileIdx).
- `simulateOneTick` with one plantSeed command applies it AND updates `lastTickAt: currentTick`. - `simulateOneTick` with one plantSeed command applies it AND increments `tickCount` by 1 (BLOCKER 3 — sim writes tickCount, not lastTickAt).
- `simulateOneTick` with no commands updates only `lastTickAt`. - `simulateOneTick` with no commands still increments `tickCount` (the sim ticked, even with no player commands).
- `simulateOneTick` does NOT modify `lastTickAt` (BLOCKER 3 — saveSync owns that field).
- `tileGrowthStage` returns null for empty tile, returns the correct stage for a plant. - `tileGrowthStage` returns null for empty tile, returns the correct stage for a plant.
**Step 7 — `src/sim/garden/index.ts`** — barrel: **Step 7 — `src/sim/garden/index.ts`** — barrel:
@@ -500,6 +504,9 @@ Also extend `src/sim/index.ts` to re-export `* from './garden'` (or specific sym
- `grep -q "durationTicks: 600" src/sim/garden/plants.ts` (rosemary) - `grep -q "durationTicks: 600" src/sim/garden/plants.ts` (rosemary)
- `grep -q "durationTicks: 1500" src/sim/garden/plants.ts` (winter-rose) - `grep -q "durationTicks: 1500" src/sim/garden/plants.ts` (winter-rose)
- `grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts` (none of these may contain Date.now per the ESLint rule) - `grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts` (none of these may contain Date.now per the ESLint rule)
- `! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts` (BLOCKER 3 — sim must NEVER write lastTickAt; saveSync owns it)
- `grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts` (BLOCKER 3 — simulateOneTick increments the sim-internal counter)
- `! grep -E "lastTickAt:\\s*this\\." src/game/scenes/Garden.ts` (BLOCKER 3 — Garden scene snapshot must read lastTickAt from store, not write a tick counter into it)
- `npx vitest run src/sim/garden/` exits 0 with ≥15 passing tests - `npx vitest run src/sim/garden/` exits 0 with ≥15 passing tests
- `npm run lint` exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here) - `npm run lint` exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here)
- `npm run build` exits 0 - `npm run build` exits 0
@@ -798,11 +805,17 @@ export class Garden extends Phaser.Scene {
// Build current SimState snapshot from the store + drain commands. // Build current SimState snapshot from the store + drain commands.
const storeState = appStore.getState(); const storeState = appStore.getState();
const commands = simAdapter.drainCommands(); const commands = simAdapter.drainCommands();
// BLOCKER 3 fix — DO NOT seed lastTickAt with this.currentTick. lastTickAt
// is wall-clock ms owned by saveSync. The Garden scene's snapshot copies
// the value already in the store (which was hydrated from the save and
// hasn't been touched since). tickCount is the sim's own counter and is
// similarly read-through.
const simStateNow: SimState = { const simStateNow: SimState = {
garden: { tiles: storeState.tiles }, garden: { tiles: storeState.tiles },
plants: [], plants: [],
harvestedFragmentIds: storeState.harvestedFragmentIds, harvestedFragmentIds: storeState.harvestedFragmentIds,
lastTickAt: this.currentTick, lastTickAt: storeState.lastTickAt ?? 0, // read-through from store; sim never writes
tickCount: this.currentTick, // local sim counter — sim writes this field
unlockedPlantTypes: storeState.unlockedPlantTypes, unlockedPlantTypes: storeState.unlockedPlantTypes,
luraBeatProgress: storeState.luraBeatProgress, luraBeatProgress: storeState.luraBeatProgress,
offlineEvents: null, offlineEvents: null,
@@ -799,13 +799,19 @@ const btnStyle: React.CSSProperties = {
fontFamily: 'serif', textAlign: 'left', width: '100%', fontFamily: 'serif', textAlign: 'left', width: '100%',
}; };
// Helpers — these live here for now; can be extracted to src/save/ if reused // 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<typeof useAppStore.getState>): V1Payload { function buildPayloadFromStore(s: ReturnType<typeof useAppStore.getState>): V1Payload {
return { return {
garden: { tiles: s.tiles }, garden: { tiles: s.tiles },
plants: [], plants: [],
harvestedFragmentIds: s.harvestedFragmentIds, harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt: Date.now(), lastTickAt: Date.now(),
tickCount: s.tickCount ?? 0,
unlockedPlantTypes: s.unlockedPlantTypes, unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress, luraBeatProgress: s.luraBeatProgress,
offlineEvents: null, offlineEvents: null,
@@ -822,6 +828,9 @@ function hydrateStoreFromPayload(payload: V1Payload): void {
state.setHarvested(payload.harvestedFragmentIds); state.setHarvested(payload.harvestedFragmentIds);
state.setLuraBeatProgress(payload.luraBeatProgress); state.setLuraBeatProgress(payload.luraBeatProgress);
state.setPersistenceToastShown(payload.settings.persistenceToastShown); 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);
} }
``` ```
@@ -1085,7 +1094,8 @@ function buildPayloadFromStore(s: ReturnType<typeof appStore.getState>, lastTick
garden: { tiles: s.tiles }, garden: { tiles: s.tiles },
plants: [], plants: [],
harvestedFragmentIds: s.harvestedFragmentIds, harvestedFragmentIds: s.harvestedFragmentIds,
lastTickAt, lastTickAt, // wall-clock ms, owned by saveSync
tickCount: s.tickCount ?? 0, // BLOCKER 3 — sim-internal counter from store
unlockedPlantTypes: s.unlockedPlantTypes, unlockedPlantTypes: s.unlockedPlantTypes,
luraBeatProgress: s.luraBeatProgress, luraBeatProgress: s.luraBeatProgress,
offlineEvents: null, offlineEvents: null,
@@ -1098,6 +1108,9 @@ function hydrateStoreFromPayload(payload: V1Payload): void {
state.setHarvested(payload.harvestedFragmentIds ?? []); state.setHarvested(payload.harvestedFragmentIds ?? []);
state.setLuraBeatProgress(payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null }); state.setLuraBeatProgress(payload.luraBeatProgress ?? { arrived: false, mid: false, farewell: false, pending: null });
state.setPersistenceToastShown(payload.settings?.persistenceToastShown ?? false); 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);
} }
``` ```