feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks

- Zustand 5 vanilla createStore composes 4 slices (garden / memory /
  narrative / session); useAppStore React hook re-renders on selector
  change; getState() works without React (Phaser ↔ React bridge per D-32)
- simAdapter exposes drainCommands / applyTilesAndUnlocks /
  applyHarvestedFragments / applyLuraProgress / applyTickCount; sim
  never imports the store (CORE-10)
- V1Payload extended in place per D-34: tickCount (BLOCKER 3 monotonic
  counter), unlockedPlantTypes, luraBeatProgress, offlineEvents,
  settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays 1, no
  migrations[2] sneaked in (regression-defense test pins this)
- migrations[1] populates all new field defaults; tickCount: 0 means
  fresh sims always start at sim-tick 0
- registerSaveLifecycleHooks (UX-10): visibilitychange→hidden,
  beforeunload, plus saveOnSeasonTransition() — Vitest covers all three
- Phaser EventBus singleton seeded per the Phaser 4 React-template pattern
- Install @testing-library/react as devDep so the React-hook test can
  exercise the real renderHook surface
- 27 new tests across store / migrations / lifecycle all green; full
  npm run ci is 126/126
This commit is contained in:
2026-05-09 09:18:43 -04:00
parent 58db53227c
commit fe99058040
17 changed files with 838 additions and 10 deletions
+182
View File
@@ -23,6 +23,7 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.18",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -40,6 +41,43 @@
"vitest": "^4.1.5"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@boundaries/elements": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz",
@@ -669,6 +707,55 @@
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -680,6 +767,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1548,6 +1643,17 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -1573,6 +1679,17 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1757,6 +1874,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1767,6 +1895,14 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2555,6 +2691,14 @@
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
@@ -3258,6 +3402,36 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3289,6 +3463,14 @@
"react": "^19.2.6"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+1
View File
@@ -31,6 +31,7 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.18",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+17
View File
@@ -0,0 +1,17 @@
import * as Phaser from 'phaser';
/**
* Single shared emitter — the Phaser 4 React-template pattern.
* Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future
*
* Used for transient signals between Phaser scenes and React UI:
* 'scene-ready' (Phaser → React) signals scene tree is live
* 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY}
* for seed picker (Plan 02-02)
* 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal
* modal (Plan 02-03)
*
* Persistent state lives in src/store/, NOT here. Anti-pattern: routing
* user-input intents through this bus — those are commands, store-bound.
*/
export const eventBus = new Phaser.Events.EventEmitter();
+4 -1
View File
@@ -10,7 +10,10 @@ export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
export type { SaveEnvelope } from './envelope';
export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
export type { V1Payload } from './migrations';
export type { V1Payload, OfflineEventBlock } from './migrations';
export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle';
export type { LifecycleHooksHandle, LifecycleHooksConfig } from './lifecycle';
export { snapshot, listSnapshots } from './snapshots';
export type { SnapshotEntry } from './snapshots';
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle';
// happy-dom (configured via vitest.config.ts) gives us document + window.
// We dispatch real Events and observe the spy so we exercise the real
// EventTarget machinery rather than a hand-rolled stub.
describe('registerSaveLifecycleHooks (UX-10)', () => {
let handle: ReturnType<typeof registerSaveLifecycleHooks> | null = null;
beforeEach(() => {
handle = null;
});
afterEach(() => {
handle?.detach();
handle = null;
});
it('saveSync fires when visibilitychange→hidden is dispatched', () => {
const spy = vi.fn();
handle = registerSaveLifecycleHooks({ saveSync: spy });
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('saveSync does NOT fire when visibilitychange→visible is dispatched', () => {
const spy = vi.fn();
handle = registerSaveLifecycleHooks({ saveSync: spy });
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(spy).not.toHaveBeenCalled();
});
it('saveSync fires when beforeunload is dispatched', () => {
const spy = vi.fn();
handle = registerSaveLifecycleHooks({ saveSync: spy });
window.dispatchEvent(new Event('beforeunload'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('detach() removes both listeners (subsequent dispatches do not invoke spy)', () => {
const spy = vi.fn();
handle = registerSaveLifecycleHooks({ saveSync: spy });
handle.detach();
handle = null;
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('beforeunload'));
expect(spy).not.toHaveBeenCalled();
});
});
describe('saveOnSeasonTransition (UX-10 third trigger)', () => {
it('invokes the saveSync callback exactly once', () => {
const spy = vi.fn();
saveOnSeasonTransition(spy);
expect(spy).toHaveBeenCalledTimes(1);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Save lifecycle hooks (UX-10).
*
* Saves fire on:
* 1. visibilitychange → hidden
* 2. beforeunload
* 3. saveOnSeasonTransition() (callable from Phase 4+; Phase 2 verifies
* via unit test only)
*
* The visibilitychange + beforeunload handlers MUST be synchronous (no
* `await`) — RESEARCH Pitfall 7 line 1094: React unmounts asynchronously
* and `beforeunload` will not await. The synchronous LocalStorageDBAdapter
* write path is used here; idb writes are best-effort.
*/
export interface LifecycleHooksHandle {
/** Detach all listeners. Call from a useEffect cleanup function. */
detach(): void;
}
export interface LifecycleHooksConfig {
/** Synchronous serializer that writes to LocalStorage and best-effort to IDB. */
saveSync: () => void;
}
export function registerSaveLifecycleHooks(
config: LifecycleHooksConfig,
): LifecycleHooksHandle {
const onVisibility = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
config.saveSync();
}
};
const onBeforeUnload = (): void => {
config.saveSync();
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibility);
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', onBeforeUnload);
}
return {
detach() {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibility);
}
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', onBeforeUnload);
}
},
};
}
/**
* Phase-4+ hook for Season transitions. Phase 2 has no transitions; this
* function is exported so Phase 4's prestige plan can call it directly
* (UX-10 third trigger).
*/
export function saveOnSeasonTransition(saveSync: () => void): void {
saveSync();
}
+53 -1
View File
@@ -4,9 +4,14 @@ import { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
// Tests for the forward-only migration registry. The synthetic v0 → v1
// migration (CONTEXT D-05) is the load-bearing one — Phase 4's real
// migrate_v1_to_v2 will follow the exact same shape.
//
// Phase 2 (CONTEXT D-34) extends V1Payload IN PLACE rather than introducing
// migrations[2] — Phase 1's v1 has shipped no production saves, so adding
// fields with sensible defaults is preferable. The block of "new field
// default" tests below pins the extension contract.
describe('CURRENT_SCHEMA_VERSION', () => {
it('is 1 in Phase 1 (sanity)', () => {
it('is 1 (Phase 2 extends v1 in place per D-34, no migrations[2])', () => {
expect(CURRENT_SCHEMA_VERSION).toBe(1);
});
});
@@ -62,3 +67,50 @@ describe('migrate (synthetic v0 → v1 per CONTEXT D-04 + D-05)', () => {
}
});
});
describe('Phase 2 V1Payload extension defaults (CONTEXT D-34)', () => {
// After D-34 every v0 → v1 migration MUST populate the new fields.
// These tests pin the contract so a future regression that drops a
// default is caught.
it('migrations[1] populates unlockedPlantTypes as []', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.unlockedPlantTypes).toEqual([]);
});
it('migrations[1] populates luraBeatProgress with all-false defaults', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.luraBeatProgress).toEqual({
arrived: false,
mid: false,
farewell: false,
pending: null,
});
});
it('migrations[1] populates offlineEvents as null (no events on a fresh save)', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.offlineEvents).toBeNull();
});
it('migrations[1] populates settings.persistenceToastShown as false (D-30 toast not yet seen)', () => {
const out = migrations[1]({ garden: ['x'] }) as { settings: { persistenceToastShown: boolean } };
expect(out.settings.persistenceToastShown).toBe(false);
});
it('migrations[1] preserves existing audio volume defaults (musicVolume 0.7)', () => {
const out = migrations[1]({ garden: ['x'] }) as { settings: { musicVolume: number } };
expect(out.settings.musicVolume).toBe(0.7);
});
it('BLOCKER 3: migrations[1] populates tickCount as 0 (sim-internal counter starts fresh)', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.tickCount).toBe(0);
});
});
describe('migration registry shape (D-34 regression defense)', () => {
it('only migrations[1] is registered (no migrations[2] sneakily added)', () => {
expect(Object.keys(migrations).sort()).toEqual(['1']);
});
});
+68 -8
View File
@@ -6,10 +6,10 @@
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
* migrations[2] when prestige / Roothold state lands.
*
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
* Phase 2's first feature commit will write. Authoring it now lets us
* prove the migration chain end-to-end without speculating about future
* Season 5+ structures.
* Phase 2 EXTENDS V1Payload in place per CONTEXT D-34 — Phase 1's v1
* has shipped no production saves, so adding fields with sensible
* defaults is preferable to a no-op migrations[2]. CURRENT_SCHEMA_VERSION
* stays at 1.
*/
type Migration = (payload: unknown) => unknown;
@@ -21,27 +21,76 @@ interface V0Payload {
}
/**
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
* v1 save shape — Phase-2-extended per CONTEXT D-34.
*
* NOTE: This is an EXTENSION, not a migration. Phase 1's v1 has shipped
* no production saves; Phase 2 adds fields with sensible defaults rather
* than introducing migrations[2]. The first real v1→v2 migration lands
* in Phase 4 (Roothold / prestige state).
*
* Cross-references:
* - tickCount → BLOCKER 3 (sim-internal monotonic counter)
* - unlockedPlantTypes → CONTEXT D-05 (plant-type unlocks via fragment count)
* - luraBeatProgress → CONTEXT D-13 / D-14 (3 beats: arrival / mid / farewell)
* - offlineEvents → CONTEXT D-19 (offline event log feeding the letter)
* - settings.persistenceToastShown → CONTEXT D-30 (one-time soft toast)
*/
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;
mid: boolean;
farewell: boolean;
pending: 'arrival' | 'mid' | 'farewell' | null;
};
offlineEvents: OfflineEventBlock | null;
settings: {
musicVolume: number;
ambientVolume: number;
sfxVolume: number;
persistenceToastShown: boolean;
};
}
/**
* Local mirror of the OfflineEventBlock shape — declared HERE rather
* than imported from src/sim/offline/ so the save layer remains a leaf
* with no upward dependency on sim. The Zod schema lives in
* src/sim/offline/ (Plan 02-05); structural compatibility is enforced
* via TypeScript at the application boundary (src/store/sim-adapter.ts).
*/
export interface OfflineEventBlock {
plantsBloomedCount: Record<string, number>;
harvestedFragmentIds: string[];
luraBeatPending: 'arrival' | 'mid' | 'farewell' | null;
}
/**
* Forward-only migration chain. Keys are TARGET versions; the function
* at key N migrates FROM N-1 TO N.
*
* - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05).
* - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05). Phase 2
* updates the body to populate the new field defaults; the schema
* version itself stays at 1 (per D-34 — extension, not migration).
* - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold /
* prestige state lands.
*/
@@ -53,10 +102,21 @@ export const migrations: Record<number, Migration> = {
plants: [],
harvestedFragmentIds: [],
lastTickAt: Date.now(),
// Phase 2 (D-34) defaults:
tickCount: 0, // BLOCKER 3 — fresh sim starts at tick 0
unlockedPlantTypes: [],
luraBeatProgress: {
arrived: false,
mid: false,
farewell: false,
pending: null,
},
offlineEvents: null,
settings: {
musicVolume: 0.7,
ambientVolume: 0.5,
sfxVolume: 0.8,
persistenceToastShown: false,
},
};
},
+57
View File
@@ -0,0 +1,57 @@
import type { StateCreator } from 'zustand';
/**
* GardenSlice — Phase 2 garden state surface (D-01 through D-07).
*
* The 16 tiles + unlocked plant types + queued commands. Wave-1 Plan 02-02
* (Begin/Plant/Grow) and Plan 02-03 (Harvest/Journal) flesh out the tile
* data; Wave 0 ships the slice shape so React can subscribe immediately.
*
* BLOCKER 3 invariant — two distinct time fields:
* - tickCount: monotonic sim-internal counter; written via setTickCount
* by simAdapter.applyTickCount.
* - lastTickAt: wall-clock ms; written via setLastTickAt at saveSync time
* by the application layer (NOT by the sim).
*/
export interface GardenCommand {
kind: 'plantSeed' | 'harvest' | 'compost';
tileIdx: number;
plantTypeId?: string; // only for plantSeed
}
export interface GardenSlice {
/** length 16; Plan 02-02 fills with the real Tile interface. */
tiles: unknown[];
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: () => {
const cmds = get().pendingCommands;
set({ pendingCommands: [] });
return cmds;
},
applyTilesAndUnlocks: (tiles, unlocked) =>
set({ tiles, unlockedPlantTypes: unlocked }),
setTickCount: (n) => set({ tickCount: n }),
setLastTickAt: (ms) => set({ lastTickAt: ms }),
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Public barrel for the Zustand store + sim adapter + selectors.
* App / UI / Phaser scene code imports from here.
*/
export { appStore, useAppStore } from './store';
export type { AppStoreShape } from './store';
export { simAdapter } from './sim-adapter';
export type { GardenSlice, GardenCommand } from './garden-slice';
export type { MemorySlice } from './memory-slice';
export type { NarrativeSlice, LuraBeatId } from './narrative-slice';
export type { SessionSlice } from './session-slice';
export * from './selectors';
+24
View File
@@ -0,0 +1,24 @@
import type { StateCreator } from 'zustand';
/**
* MemorySlice — harvested fragment IDs + just-harvested reveal modal state.
*
* Per D-25, when the player harvests during active play we surface the
* fragment in a non-modal reveal so the journal isn't the only entry point.
* `fragmentRevealId` is the transient signal; the canonical list of
* harvested IDs is `harvestedFragmentIds` (mirrors V1Payload.harvestedFragmentIds).
*/
export interface MemorySlice {
harvestedFragmentIds: string[];
/** Reveal modal state — D-25 surfaces just-harvested fragment in active play. */
fragmentRevealId: string | null;
setHarvested: (ids: string[]) => void;
setFragmentRevealId: (id: string | null) => void;
}
export const createMemorySlice: StateCreator<MemorySlice, [], [], MemorySlice> = (set) => ({
harvestedFragmentIds: [],
fragmentRevealId: null,
setHarvested: (ids) => set({ harvestedFragmentIds: ids }),
setFragmentRevealId: (id) => set({ fragmentRevealId: id }),
});
+29
View File
@@ -0,0 +1,29 @@
import type { StateCreator } from 'zustand';
/**
* NarrativeSlice — Lura beat progress + dialogue overlay open state.
*
* Per D-13 / D-14 there are three Lura beats per Season-1 arc:
* arrival, mid, farewell. `pending` is set when the gate condition is
* met but the dialogue overlay hasn't been triggered yet.
*/
export type LuraBeatId = 'arrival' | 'mid' | 'farewell';
export interface NarrativeSlice {
luraBeatProgress: {
arrived: boolean;
mid: boolean;
farewell: boolean;
pending: LuraBeatId | null;
};
dialogueOverlayOpen: boolean;
setLuraBeatProgress: (p: NarrativeSlice['luraBeatProgress']) => void;
setDialogueOverlayOpen: (open: boolean) => void;
}
export const createNarrativeSlice: StateCreator<NarrativeSlice, [], [], NarrativeSlice> = (set) => ({
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
dialogueOverlayOpen: false,
setLuraBeatProgress: (p) => set({ luraBeatProgress: p }),
setDialogueOverlayOpen: (open) => set({ dialogueOverlayOpen: open }),
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Named selectors for use with `useAppStore(...)` in React components.
* Co-locating them keeps shape changes to AppStoreShape contained — call
* sites name selectors instead of inlining shape access.
*/
import type { AppStoreShape } from './store';
export const selectHarvestCount = (s: AppStoreShape): number =>
s.harvestedFragmentIds.length;
export const selectJournalRevealed = (s: AppStoreShape): boolean =>
s.harvestedFragmentIds.length > 0;
export const selectBeginGateActive = (s: AppStoreShape): boolean =>
!s.beginGateDismissed;
export const selectLuraPending = (s: AppStoreShape) =>
s.luraBeatProgress.pending;
+32
View File
@@ -0,0 +1,32 @@
import type { StateCreator } from 'zustand';
/**
* SessionSlice — transient per-session UI state.
*
* Per D-30 the persistence toast is shown once on first run; per D-19
* the letter overlay surfaces the offline event block when the player
* returns. None of this is persisted to disk (it's per-session); the
* one-time-toast guard IS persisted via V1Payload.settings.persistenceToastShown.
*/
export interface SessionSlice {
beginGateDismissed: boolean;
persistenceToastShown: boolean;
letterOverlayOpen: boolean;
/** OfflineEventBlock; typed in Plan 02-05 when the offline pipeline lands. */
pendingLetterEventBlock: unknown | null;
dismissBeginGate: () => void;
setPersistenceToastShown: (v: boolean) => void;
openLetter: (block: unknown) => void;
dismissLetter: () => void;
}
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
beginGateDismissed: false,
persistenceToastShown: false,
letterOverlayOpen: false,
pendingLetterEventBlock: null,
dismissBeginGate: () => set({ beginGateDismissed: true }),
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
});
+45
View File
@@ -0,0 +1,45 @@
/**
* simAdapter — the application-layer boundary between the pure sim and
* the Zustand store.
*
* The Phaser scene's update() loop calls these:
* 1. drainCommands() — pull pending commands the React UI enqueued
* 2. (run scheduler with those commands; receive next state + events)
* 3. applyTilesAndUnlocks / applyHarvestedFragments / applyLuraProgress /
* applyTickCount — write the result back into the store
*
* src/sim/ MUST NOT import this file. The CORE-10 firewall (sim → ui)
* already prevents that; this comment is a reader-facing reminder.
*
* BLOCKER 3 — applyTickCount is the sim → store data flow path for the
* monotonic counter. The sim never writes the store directly; the
* application layer pulls the next state out of the sim and pushes it
* here.
*/
import { appStore } from './store';
import type { GardenCommand } from './garden-slice';
export const simAdapter = {
drainCommands(): GardenCommand[] {
return appStore.getState().drainCommands();
},
applyTilesAndUnlocks(tiles: unknown[], unlocked: string[]): void {
appStore.getState().applyTilesAndUnlocks(tiles, unlocked);
},
applyHarvestedFragments(ids: string[]): void {
appStore.getState().setHarvested(ids);
},
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);
},
};
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { appStore, useAppStore } from './store';
import { selectJournalRevealed } from './selectors';
// Reset to a fresh slice composition between tests so command queue / harvested
// list state from one test does not leak into the next.
function resetStore() {
appStore.setState({
tiles: new Array(16).fill(null),
unlockedPlantTypes: [],
tickCount: 0,
lastTickAt: 0,
pendingCommands: [],
harvestedFragmentIds: [],
fragmentRevealId: null,
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
dialogueOverlayOpen: false,
beginGateDismissed: false,
persistenceToastShown: false,
letterOverlayOpen: false,
pendingLetterEventBlock: null,
});
}
describe('appStore — slice composition (4 slices)', () => {
beforeEach(resetStore);
it('exposes all four slice keys at top level', () => {
const s = appStore.getState();
// GardenSlice keys
expect(s.pendingCommands).toEqual([]);
expect(s.tiles).toHaveLength(16);
expect(s.tickCount).toBe(0);
expect(s.lastTickAt).toBe(0);
// MemorySlice keys
expect(s.harvestedFragmentIds).toEqual([]);
// NarrativeSlice keys
expect(s.luraBeatProgress).toEqual({
arrived: false,
mid: false,
farewell: false,
pending: null,
});
// SessionSlice keys
expect(s.beginGateDismissed).toBe(false);
});
});
describe('GardenSlice — command queue semantics', () => {
beforeEach(resetStore);
it('enqueueCommand appends; drainCommands returns and clears', () => {
appStore.getState().enqueueCommand({
kind: 'plantSeed',
tileIdx: 0,
plantTypeId: 'rosemary',
});
appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: 0 });
const drained = appStore.getState().drainCommands();
expect(drained).toEqual([
{ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' },
{ kind: 'harvest', tileIdx: 0 },
]);
expect(appStore.getState().pendingCommands).toEqual([]);
});
it('drainCommands returns [] when nothing is queued', () => {
expect(appStore.getState().drainCommands()).toEqual([]);
});
});
describe('GardenSlice — BLOCKER 3 tickCount + lastTickAt round-trip', () => {
beforeEach(resetStore);
it('setTickCount(7) updates tickCount', () => {
appStore.getState().setTickCount(7);
expect(appStore.getState().tickCount).toBe(7);
});
it('setLastTickAt(1234567) updates lastTickAt', () => {
appStore.getState().setLastTickAt(1234567);
expect(appStore.getState().lastTickAt).toBe(1234567);
});
it('both fields default to 0', () => {
expect(appStore.getState().tickCount).toBe(0);
expect(appStore.getState().lastTickAt).toBe(0);
});
});
describe('useAppStore — React hook surface', () => {
beforeEach(resetStore);
it('re-renders when the selected slice value changes', () => {
const { result } = renderHook(() =>
useAppStore((s) => s.harvestedFragmentIds.length),
);
expect(result.current).toBe(0);
act(() => {
appStore.getState().setHarvested(['season1.soil.x']);
});
expect(result.current).toBe(1);
});
});
describe('selectors', () => {
it('selectJournalRevealed returns true when at least one fragment is harvested', () => {
const initial = appStore.getState();
expect(
selectJournalRevealed({ ...initial, harvestedFragmentIds: ['x'] }),
).toBe(true);
});
it('selectJournalRevealed returns false when no fragments are harvested', () => {
const initial = appStore.getState();
expect(
selectJournalRevealed({ ...initial, harvestedFragmentIds: [] }),
).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Zustand 5 vanilla store + React hook.
*
* Per D-32 + RESEARCH Pattern 3 (lines 624-661): the appStore is the
* Phaser↔React bridge. The Phaser scene's update() loop reads via
* `appStore.getState()`; React components subscribe via `useAppStore`.
* The sim NEVER imports this module (CORE-10 enforced); it goes through
* src/store/sim-adapter.ts.
*
* Composition style: zustand/vanilla `createStore` returns a vanilla
* StoreApi that works without React; `useStore(appStore, selector)` is
* the React hook surface.
*/
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
import { createGardenSlice, type GardenSlice } from './garden-slice';
import { createMemorySlice, type MemorySlice } from './memory-slice';
import { createNarrativeSlice, type NarrativeSlice } from './narrative-slice';
import { createSessionSlice, type SessionSlice } from './session-slice';
export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice;
export const appStore = createStore<AppStoreShape>()((...a) => ({
...createGardenSlice(...a),
...createMemorySlice(...a),
...createNarrativeSlice(...a),
...createSessionSlice(...a),
}));
/**
* React hook surface — re-renders subscribing components when the
* selector's return value changes (zustand defaults to `Object.is`).
*/
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T {
return useStore(appStore, selector);
}