diff --git a/package-lock.json b/package-lock.json index a2b1aa3..a7fba2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cfd68e4..ca408d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/game/event-bus.ts b/src/game/event-bus.ts new file mode 100644 index 0000000..046bfbf --- /dev/null +++ b/src/game/event-bus.ts @@ -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(); diff --git a/src/save/index.ts b/src/save/index.ts index 457487c..bbee42b 100644 --- a/src/save/index.ts +++ b/src/save/index.ts @@ -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'; diff --git a/src/save/lifecycle.test.ts b/src/save/lifecycle.test.ts new file mode 100644 index 0000000..b0e79f7 --- /dev/null +++ b/src/save/lifecycle.test.ts @@ -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 | 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); + }); +}); diff --git a/src/save/lifecycle.ts b/src/save/lifecycle.ts new file mode 100644 index 0000000..81888db --- /dev/null +++ b/src/save/lifecycle.ts @@ -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(); +} diff --git a/src/save/migrations.test.ts b/src/save/migrations.test.ts index 8e77867..e5e96a6 100644 --- a/src/save/migrations.test.ts +++ b/src/save/migrations.test.ts @@ -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; + expect(out.unlockedPlantTypes).toEqual([]); + }); + + it('migrations[1] populates luraBeatProgress with all-false defaults', () => { + const out = migrations[1]({ garden: ['x'] }) as Record; + 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; + 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; + 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']); + }); +}); diff --git a/src/save/migrations.ts b/src/save/migrations.ts index 983ebdf..896f326 100644 --- a/src/save/migrations.ts +++ b/src/save/migrations.ts @@ -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; + 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 = { 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, }, }; }, diff --git a/src/store/garden-slice.ts b/src/store/garden-slice.ts new file mode 100644 index 0000000..b49b3b9 --- /dev/null +++ b/src/store/garden-slice.ts @@ -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 = (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 }), +}); diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..1a2466b --- /dev/null +++ b/src/store/index.ts @@ -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'; diff --git a/src/store/memory-slice.ts b/src/store/memory-slice.ts new file mode 100644 index 0000000..08638b4 --- /dev/null +++ b/src/store/memory-slice.ts @@ -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 = (set) => ({ + harvestedFragmentIds: [], + fragmentRevealId: null, + setHarvested: (ids) => set({ harvestedFragmentIds: ids }), + setFragmentRevealId: (id) => set({ fragmentRevealId: id }), +}); diff --git a/src/store/narrative-slice.ts b/src/store/narrative-slice.ts new file mode 100644 index 0000000..cd48d1a --- /dev/null +++ b/src/store/narrative-slice.ts @@ -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 = (set) => ({ + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + dialogueOverlayOpen: false, + setLuraBeatProgress: (p) => set({ luraBeatProgress: p }), + setDialogueOverlayOpen: (open) => set({ dialogueOverlayOpen: open }), +}); diff --git a/src/store/selectors.ts b/src/store/selectors.ts new file mode 100644 index 0000000..e55b739 --- /dev/null +++ b/src/store/selectors.ts @@ -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; diff --git a/src/store/session-slice.ts b/src/store/session-slice.ts new file mode 100644 index 0000000..90b8727 --- /dev/null +++ b/src/store/session-slice.ts @@ -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 = (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 }), +}); diff --git a/src/store/sim-adapter.ts b/src/store/sim-adapter.ts new file mode 100644 index 0000000..070542d --- /dev/null +++ b/src/store/sim-adapter.ts @@ -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); + }, +}; diff --git a/src/store/store.test.ts b/src/store/store.test.ts new file mode 100644 index 0000000..bc69bca --- /dev/null +++ b/src/store/store.test.ts @@ -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); + }); +}); diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..3872514 --- /dev/null +++ b/src/store/store.ts @@ -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()((...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(selector: (s: AppStoreShape) => T): T { + return useStore(appStore, selector); +}