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:
Generated
+182
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -40,6 +41,43 @@
|
|||||||
"vitest": "^4.1.5"
|
"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": {
|
"node_modules/@boundaries/elements": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz",
|
||||||
@@ -669,6 +707,55 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -680,6 +767,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -1548,6 +1643,17 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -1573,6 +1679,17 @@
|
|||||||
"sprintf-js": "~1.0.2"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1757,6 +1874,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1767,6 +1895,14 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -2555,6 +2691,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
@@ -3258,6 +3402,36 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -3289,6 +3463,14 @@
|
|||||||
"react": "^19.2.6"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -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
@@ -10,7 +10,10 @@ export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
|
|||||||
export type { SaveEnvelope } from './envelope';
|
export type { SaveEnvelope } from './envelope';
|
||||||
|
|
||||||
export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
|
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 { snapshot, listSnapshots } from './snapshots';
|
||||||
export type { SnapshotEntry } from './snapshots';
|
export type { SnapshotEntry } from './snapshots';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -4,9 +4,14 @@ import { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
|
|||||||
// Tests for the forward-only migration registry. The synthetic v0 → v1
|
// Tests for the forward-only migration registry. The synthetic v0 → v1
|
||||||
// migration (CONTEXT D-05) is the load-bearing one — Phase 4's real
|
// migration (CONTEXT D-05) is the load-bearing one — Phase 4's real
|
||||||
// migrate_v1_to_v2 will follow the exact same shape.
|
// 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', () => {
|
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);
|
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
@@ -6,10 +6,10 @@
|
|||||||
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
|
* (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land
|
||||||
* migrations[2] when prestige / Roothold state lands.
|
* migrations[2] when prestige / Roothold state lands.
|
||||||
*
|
*
|
||||||
* The v1 shape (from CONTEXT D-04) is intentionally minimal: only what
|
* Phase 2 EXTENDS V1Payload in place per CONTEXT D-34 — Phase 1's v1
|
||||||
* Phase 2's first feature commit will write. Authoring it now lets us
|
* has shipped no production saves, so adding fields with sensible
|
||||||
* prove the migration chain end-to-end without speculating about future
|
* defaults is preferable to a no-op migrations[2]. CURRENT_SCHEMA_VERSION
|
||||||
* Season 5+ structures.
|
* stays at 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Migration = (payload: unknown) => unknown;
|
type Migration = (payload: unknown) => unknown;
|
||||||
@@ -21,27 +21,76 @@ interface V0Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth
|
* v1 save shape — Phase-2-extended per CONTEXT D-34.
|
||||||
* data placeholder, harvested fragment IDs, last tick timestamp, settings.
|
*
|
||||||
* Phase 2 fleshes the contents; Phase 1 just locks the field set.
|
* 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 {
|
export interface V1Payload {
|
||||||
garden: { tiles: unknown[] };
|
garden: { tiles: unknown[] };
|
||||||
plants: unknown[];
|
plants: unknown[];
|
||||||
harvestedFragmentIds: string[];
|
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;
|
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: {
|
settings: {
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
ambientVolume: number;
|
ambientVolume: number;
|
||||||
sfxVolume: 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
|
* Forward-only migration chain. Keys are TARGET versions; the function
|
||||||
* at key N migrates FROM N-1 TO N.
|
* 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 /
|
* - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold /
|
||||||
* prestige state lands.
|
* prestige state lands.
|
||||||
*/
|
*/
|
||||||
@@ -53,10 +102,21 @@ export const migrations: Record<number, Migration> = {
|
|||||||
plants: [],
|
plants: [],
|
||||||
harvestedFragmentIds: [],
|
harvestedFragmentIds: [],
|
||||||
lastTickAt: Date.now(),
|
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: {
|
settings: {
|
||||||
musicVolume: 0.7,
|
musicVolume: 0.7,
|
||||||
ambientVolume: 0.5,
|
ambientVolume: 0.5,
|
||||||
sfxVolume: 0.8,
|
sfxVolume: 0.8,
|
||||||
|
persistenceToastShown: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user