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.
- BLOCKER 1: PhaserGame.tsx boot path now runs unwrap(env) → migrate(raw, env.schemaVersion).
Casting unwrap(record.envelope) directly to V1Payload silently accepted any
future-shape payload as the current shape; only migrate() walks the schema
version chain.
- BLOCKER 2: Settings.tsx onImport now correctly orders importFromBase64 →
unwrap (CRC verify) → migrate. Previous code discarded migrate's result
and then read v1.payload as if unwrap returned an envelope rather than
the payload itself — runtime crash on every import.
- BLOCKER 3: documented the lastTickAt invariant as wall-clock milliseconds,
written ONLY at saveSync time (never by the sim). Added acceptance_criteria
greps proving (a) saveSync writes clock.now(), (b) Garden scene does not
overwrite lastTickAt with a tick counter, (c) sim/garden/Garden.ts (if it
exists; the Garden scene actually lives at src/game/scenes/Garden.ts)
contains no lastTickAt: this.* writes.
- W2: D-29 keyboard shortcut wired in App.tsx — comma toggles Settings,
'j' dispatches a window CustomEvent the JournalIcon picks up.
- W5: lifecycle handle now stored in useRef and detached in the OUTER
useLayoutEffect cleanup (the previous IIFE-internal return was a closure
return, never reaching React's effect cleanup contract).