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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user