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:
@@ -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 { 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';
|
||||
|
||||
@@ -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
|
||||
// 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
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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