From e5c55b0aaeae00cb31f15b65337b54fbfc600845 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 03:04:45 -0400 Subject: [PATCH] =?UTF-8?q?revise(02):=20BLOCKER=203=20=E2=80=94=20split?= =?UTF-8?q?=20lastTickAt=20(wall-clock)=20from=20tickCount=20(sim=20counte?= =?UTF-8?q?r)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../02-01-foundations-PLAN.md | 57 +++++++++++++++++-- .../02-02-begin-plant-grow-PLAN.md | 21 +++++-- .../02-05-letter-settings-e2e-PLAN.md | 17 +++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md index 5139582..71c79b1 100644 --- a/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md @@ -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 = (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 = 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) diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md index bb443de..66c633f 100644 --- a/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md @@ -451,7 +451,10 @@ export function simulateOneTick(state: SimState, currentTick: number, commands: } // 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, '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). -- `simulateOneTick` with one plantSeed command applies it AND updates `lastTickAt: currentTick`. -- `simulateOneTick` with no commands updates only `lastTickAt`. +- `simulateOneTick` with one plantSeed command applies it AND increments `tickCount` by 1 (BLOCKER 3 — sim writes tickCount, not 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. **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: 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 -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 - `npm run lint` exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here) - `npm run build` exits 0 @@ -798,11 +805,17 @@ export class Garden extends Phaser.Scene { // Build current SimState snapshot from the store + drain commands. const storeState = appStore.getState(); 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 = { garden: { tiles: storeState.tiles }, plants: [], 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, luraBeatProgress: storeState.luraBeatProgress, offlineEvents: null, 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 fb163c0..ffe8efa 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 @@ -799,13 +799,19 @@ const btnStyle: React.CSSProperties = { 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): 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, @@ -822,6 +828,9 @@ function hydrateStoreFromPayload(payload: V1Payload): void { 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); } ``` @@ -1085,7 +1094,8 @@ function buildPayloadFromStore(s: ReturnType, lastTick garden: { tiles: s.tiles }, plants: [], 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, luraBeatProgress: s.luraBeatProgress, offlineEvents: null, @@ -1098,6 +1108,9 @@ function hydrateStoreFromPayload(payload: V1Payload): void { 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); } ```