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)"
- "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/"
- "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown — 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"
- "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 (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"
- "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"
@@ -106,7 +107,7 @@ must_haves:
- from: src/save/migrations.ts
to: "extended V1Payload"
via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown"
pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown"
pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown|tickCount"
- from: eslint.config.js
to: src/sim/scheduler/clock.ts
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)
* 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 {
garden: { tiles: unknown[] };
plants: unknown[];
harvestedFragmentIds: string[];
/** Wall-clock milliseconds at last save. Written ONLY at saveSync. */
lastTickAt: number;
/** Monotonic sim tick counter. Incremented by the sim; used for STRY-10. */
tickCount: number;
unlockedPlantTypes: string[];
luraBeatProgress: {
arrived: boolean;
@@ -557,9 +570,21 @@ export interface V1Payload {
garden: { tiles: unknown[] };
plants: unknown[];
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;
// 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[];
luraBeatProgress: {
arrived: boolean;
@@ -601,6 +626,7 @@ Update `migrations[1]` body to populate the new defaults:
plants: [],
harvestedFragmentIds: [],
lastTickAt: Date.now(),
tickCount: 0, // BLOCKER 3 — fresh sim starts at tick 0
unlockedPlantTypes: [],
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: 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]({...}).settings.persistenceToastShown` is `false`.
- `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.
@@ -652,15 +679,25 @@ export interface GardenCommand {
export interface GardenSlice {
tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface
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[];
enqueueCommand: (cmd: GardenCommand) => void;
drainCommands: () => GardenCommand[];
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) => ({
tiles: new Array(16).fill(null),
unlockedPlantTypes: [],
tickCount: 0,
lastTickAt: 0,
pendingCommands: [],
enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })),
drainCommands: () => {
@@ -669,6 +706,8 @@ export const createGardenSlice: StateCreator<GardenSlice, [], [], GardenSlice> =
return cmds;
},
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 {
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:
- 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 === []`.
- 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.
- 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 "luraBeatProgress" 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 -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 "export const appStore" src/store/store.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 "saveOnSeasonTransition" src/save/lifecycle.ts`
- `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added)