From 16e54dcd84293b92ad04dd96d8dae85b2ddc067c Mon Sep 17 00:00:00 2001 From: clem Date: Thu, 26 Mar 2026 23:56:34 +0800 Subject: [PATCH 01/12] docs: add design spec for centralized event emitter with save/load/navigate hooks (#129) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-26-event-emitter-hooks-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-event-emitter-hooks-design.md diff --git a/docs/superpowers/specs/2026-03-26-event-emitter-hooks-design.md b/docs/superpowers/specs/2026-03-26-event-emitter-hooks-design.md new file mode 100644 index 0000000..c5e2391 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-event-emitter-hooks-design.md @@ -0,0 +1,166 @@ +# Centralized Event Emitter with Save/Load/Navigate Hooks + +**Issue:** #129 +**Date:** 2026-03-26 + +## Problem + +Host applications (e.g., RoidRage) monkey-patch `Story.save()`, `Story.load()`, and `Story.goto()` to inject behavior around these operations. This is fragile — on restart, each `boot()` wraps the methods again, creating a growing chain of closures. The host can't cleanly restore the originals without tracking them manually. + +Additionally, the event listener infrastructure is scattered across multiple modules — `storyInitListeners[]` and `beforeRestartListeners[]` in `store.ts`, a `listeners` Set in `action-registry.ts`, and Zustand subscriptions in `story-api.ts`. There is no unified mechanism. + +## Solution + +1. Create a centralized event emitter module (`src/event-emitter.ts`) +2. Migrate all existing event listener storage into it +3. Add before/after hooks for save, load, and navigate operations + +Combined with the auto-clean runtime handlers from #128/#130, hooks registered during `storyinit` or `boot()` are automatically cleaned up on restart. + +## Event Emitter Module + +New file: `src/event-emitter.ts` + +### API + +```typescript +function on(event: E, cb: EventMap[E]): () => void; +function emit( + event: E, + ...args: Parameters +): void; +``` + +- `on()` returns an unsubscribe function (same contract as existing listeners) +- `on()` throws for unknown event names (validates against `EventMap` keys) +- `emit()` calls all registered callbacks synchronously, in registration order +- Internal storage: `Map>` +- No `reset()` method — cleanup is handled by the existing `trackRuntimeUnsub` mechanism + +### EventMap + +```typescript +type EventMap = { + // Existing events (migrated) + storyinit: () => void; + beforerestart: () => void; + actionsChanged: () => void; + variableChanged: ( + changed: Record, + ) => void; + + // New hooks + beforesave: ( + slot: string | undefined, + custom: Record | undefined, + ) => void; + aftersave: (slot: string | undefined) => void; + beforeload: (slot: string | undefined) => void; + afterload: (slot: string | undefined) => void; + beforenavigate: (passageName: string) => void; + afternavigate: (to: string, from: string) => void; +}; +``` + +## Hook Firing Points + +### `navigate()` in store.ts + +``` +validate passage exists +emit('beforenavigate', passageName) ← before patch computation / state change + ... compute patches, set state, persist session ... +emit('afternavigate', passageName, previousPassage) ← after persistSession +``` + +### `goBack()` / `goForward()` in store.ts + +``` +emit('beforenavigate', targetPassage) ← before state change + ... apply patches, set state, persist session ... +emit('afternavigate', targetPassage, previousPassage) ← after persistSession +``` + +### `save()` in store.ts + +``` +emit('beforesave', slot, custom) ← before getSavePayload() + ... build payload ... +quickSave(...) + .then(() => { + emit('aftersave', slot) ← after successful write + }) +``` + +The `beforesave` hook fires synchronously before the payload snapshot. This lets the host inject variables (e.g., `Story.set('engine', serializeEngineState())`) that will be captured in the save. + +### `load()` in store.ts + +``` +loadQuickSave(...) + .then((payload) => { + emit('beforeload', slot) ← after payload retrieved, before state restore + loadFromPayload(payload) + emit('afterload', slot) ← after state fully restored + }) +``` + +The `afterload` hook fires after state is restored, so the host can read restored variables (e.g., `restoreEngineState(Story.get('engine'))`). + +## Migration of Existing Events + +| Event | Current location | After migration | +| ----------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `storyinit` | `storyInitListeners[]` array + `onStoryInit()` + `fireStoryInit()` in store.ts | Remove array and helpers. `restart()` calls `emit('storyinit')` | +| `beforerestart` | `beforeRestartListeners[]` array + `onBeforeRestart()` + `fireBeforeRestart()` in store.ts | Remove array and helpers. `restart()` calls `emit('beforerestart')` | +| `actionsChanged` | `listeners` Set + `onActionsChanged()` + `notify()` in action-registry.ts | Remove Set and helpers. `notify()` calls `emit('actionsChanged')` | +| `variableChanged` | Zustand subscription created inline in story-api.ts `on()` | Subscription logic stays in story-api.ts but calls `emit('variableChanged', changed)`. Listener storage moves to emitter. | + +## Story.on() Simplification + +`Story.on()` in story-api.ts becomes a thin wrapper: + +```typescript +on(event: string, callback: (...args: any[]) => void): () => void { + // variableChanged needs special setup (Zustand subscription for diffing) + if (event === 'variableChanged') { + return setupVariableChangedSubscription(callback); + } + + // All other events: delegate directly to emitter + // This throws for unknown event names (emitter validates against EventMap keys) + const unsub = emitterOn(event, callback); + trackRuntimeUnsub(unsub); + return unsub; +} +``` + +The `navigate` event name is removed. `Story.on('navigate', ...)` throws `Error: spindle: Unknown event "navigate".` — same as any other invalid event name. + +## Breaking Changes + +- **`navigate` event removed.** Replace `Story.on('navigate', cb)` with `Story.on('afternavigate', cb)`. The callback signature is unchanged: `(to: string, from: string) => void`. +- **`afternavigate` does not fire on `loadFromPayload()`**. The old `navigate` event fired on any `currentPassage` change (including loads) via Zustand subscription. `afternavigate` only fires from explicit navigation: `navigate()`, `goBack()`, `goForward()`. + +## variableChanged: Special Case + +The `variableChanged` event requires a Zustand subscription to diff variable state between renders. This subscription logic remains in story-api.ts, but the listener storage and dispatch move to the emitter: + +1. On first `Story.on('variableChanged', cb)` call, a single shared Zustand subscription is created +2. The subscription diffs `prevVars` vs `state.variables` and calls `emit('variableChanged', changed)` when differences exist +3. Individual callbacks are stored in the emitter, not in the subscription closure +4. Each `on('variableChanged', cb)` call just registers with the emitter and returns an unsub — the shared subscription is set up once + +This means multiple `variableChanged` listeners share one Zustand subscription instead of creating one per listener (current behavior). The shared subscription persists for the lifetime of the page — it's a cheap no-op when there are no listeners registered. + +## Test Plan + +- **Emitter unit tests:** `on()` returns working unsub, `emit()` fires all listeners in registration order, unsubscribing mid-emit is safe, unknown event names throw +- **Hook integration tests:** verify firing order — `beforesave` fires before `getSavePayload()`, `aftersave` fires after successful write, `beforeload` fires before state restore, `afterload` fires after state restore +- **Navigate hooks:** `beforenavigate`/`afternavigate` fire from `navigate()`, `goBack()`, `goForward()` — but not from `loadFromPayload()` +- **Callback arguments:** each hook receives the correct arguments (slot, custom, passageName, etc.) +- **Side effects in beforesave:** host calls `Story.set()` inside `beforesave` callback, value appears in saved payload +- **Side effects in afterload:** host calls `Story.get()` inside `afterload` callback, value matches restored state +- **Auto-cleanup:** runtime-registered hooks are cleaned on restart; startup-registered hooks survive +- **`navigate` event removed:** `Story.on('navigate', ...)` throws an error +- **Migration parity:** existing events (`storyinit`, `beforerestart`, `actionsChanged`, `variableChanged`) continue to work identically after migration From 05a0054ac9dff66cf7b9f021b24ab6a595f6e1c9 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:02:20 +0800 Subject: [PATCH 02/12] docs: add implementation plan for event emitter hooks (#129) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-26-event-emitter-hooks.md | 1304 +++++++++++++++++ 1 file changed, 1304 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-26-event-emitter-hooks.md diff --git a/docs/superpowers/plans/2026-03-26-event-emitter-hooks.md b/docs/superpowers/plans/2026-03-26-event-emitter-hooks.md new file mode 100644 index 0000000..b1fd7aa --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-event-emitter-hooks.md @@ -0,0 +1,1304 @@ +# Event Emitter & Save/Load/Navigate Hooks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace scattered event listener arrays with a centralized event emitter and add before/after hooks for save, load, and navigate operations (#129). + +**Architecture:** New `src/event-emitter.ts` module with typed `on()`/`emit()` API. Existing events (`storyinit`, `beforerestart`, `actionsChanged`, `variableChanged`) migrate into it. Six new hooks (`beforesave`, `aftersave`, `beforeload`, `afterload`, `beforenavigate`, `afternavigate`) fire at precise points in `store.ts`. The old `navigate` event is removed (breaking change — replaced by `afternavigate`). + +**Tech Stack:** TypeScript, Vitest, Zustand (store subscriptions for `variableChanged`) + +**Spec:** `docs/superpowers/specs/2026-03-26-event-emitter-hooks-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +| ----------------------------------- | ------ | ------------------------------------------------------------------------------------------------- | +| `src/event-emitter.ts` | Create | Typed event emitter: `on()`, `emit()`, `resetEmitter()` | +| `test/unit/event-emitter.test.ts` | Create | Unit tests for the emitter module | +| `src/store.ts` | Modify | Remove listener arrays, import emitter, fire hooks in save/load/navigate/goBack/goForward/restart | +| `src/action-registry.ts` | Modify | Remove `listeners` Set and `onActionsChanged()`, import `emit` from emitter | +| `src/story-api.ts` | Modify | Simplify `on()` to delegate to emitter, shared `variableChanged` subscription | +| `test/unit/store.test.ts` | Modify | Update imports (remove `onBeforeRestart`/`onStoryInit`), add hook tests | +| `test/unit/story-api.test.ts` | Modify | Update `on(navigate)` tests → `on(afternavigate)`, add new hook tests, test `navigate` throws | +| `test/unit/action-registry.test.ts` | Modify | Update `onActionsChanged` tests to use emitter's `on()` instead | + +--- + +### Task 1: Create the event emitter module + +**Files:** + +- Create: `src/event-emitter.ts` +- Create: `test/unit/event-emitter.test.ts` + +- [ ] **Step 1: Write the failing tests for the emitter** + +Create `test/unit/event-emitter.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { on, emit, resetEmitter } from '../../src/event-emitter'; + +describe('event-emitter', () => { + beforeEach(() => { + resetEmitter(); + }); + + it('on() returns an unsub that removes the listener', () => { + const cb = vi.fn(); + const unsub = on('storyinit', cb); + emit('storyinit'); + expect(cb).toHaveBeenCalledTimes(1); + unsub(); + emit('storyinit'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('emit() calls listeners in registration order', () => { + const order: number[] = []; + on('beforerestart', () => order.push(1)); + on('beforerestart', () => order.push(2)); + on('beforerestart', () => order.push(3)); + emit('beforerestart'); + expect(order).toEqual([1, 2, 3]); + }); + + it('emit() passes arguments to listeners', () => { + const cb = vi.fn(); + on('beforesave', cb); + emit('beforesave', 'slot-1', { meta: true }); + expect(cb).toHaveBeenCalledWith('slot-1', { meta: true }); + }); + + it('emit() passes undefined args correctly', () => { + const cb = vi.fn(); + on('beforesave', cb); + emit('beforesave', undefined, undefined); + expect(cb).toHaveBeenCalledWith(undefined, undefined); + }); + + it('unsubscribing during emit does not skip listeners', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + let unsub1: () => void; + unsub1 = on('storyinit', () => { + cb1(); + unsub1(); + }); + on('storyinit', cb2); + emit('storyinit'); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('on() throws for unknown event names', () => { + expect(() => on('badEvent' as any, vi.fn())).toThrow( + 'spindle: Unknown event "badEvent"', + ); + }); + + it('emit() is a no-op for events with no listeners', () => { + expect(() => emit('storyinit')).not.toThrow(); + }); + + it('afternavigate passes (to, from) args', () => { + const cb = vi.fn(); + on('afternavigate', cb); + emit('afternavigate', 'Room', 'Start'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('resetEmitter() clears all listeners', () => { + const cb = vi.fn(); + on('storyinit', cb); + resetEmitter(); + emit('storyinit'); + expect(cb).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/event-emitter.test.ts` +Expected: FAIL — module `../../src/event-emitter` does not exist. + +- [ ] **Step 3: Implement the event emitter** + +Create `src/event-emitter.ts`: + +```typescript +type EventMap = { + storyinit: () => void; + beforerestart: () => void; + actionsChanged: () => void; + variableChanged: ( + changed: Record, + ) => void; + beforesave: ( + slot: string | undefined, + custom: Record | undefined, + ) => void; + aftersave: (slot: string | undefined) => void; + beforeload: (slot: string | undefined) => void; + afterload: (slot: string | undefined) => void; + beforenavigate: (passageName: string) => void; + afternavigate: (to: string, from: string) => void; +}; + +export type StoryEvent = keyof EventMap; +export type StoryEventCallback = EventMap[E]; + +const VALID_EVENTS = new Set([ + 'storyinit', + 'beforerestart', + 'actionsChanged', + 'variableChanged', + 'beforesave', + 'aftersave', + 'beforeload', + 'afterload', + 'beforenavigate', + 'afternavigate', +]); + +// Each event key maps to a Set of callbacks. +let listeners = new Map>(); + +export function on( + event: E, + cb: EventMap[E], +): () => void { + if (!VALID_EVENTS.has(event)) { + throw new Error(`spindle: Unknown event "${event}".`); + } + let set = listeners.get(event); + if (!set) { + set = new Set(); + listeners.set(event, set); + } + set.add(cb); + return () => { + set!.delete(cb); + }; +} + +export function emit( + event: E, + ...args: Parameters +): void { + const set = listeners.get(event); + if (!set) return; + // Snapshot to tolerate unsubscription during iteration + for (const cb of [...set]) { + (cb as Function)(...args); + } +} + +/** Test-only: clear all listeners. */ +export function resetEmitter(): void { + listeners = new Map(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/event-emitter.ts test/unit/event-emitter.test.ts +git commit -m "feat: add centralized event emitter module (#129)" +``` + +--- + +### Task 2: Migrate `storyinit` and `beforerestart` from store.ts to emitter + +**Files:** + +- Modify: `src/store.ts:219-252` (remove listener arrays and helpers) +- Modify: `src/store.ts:560,597` (replace `fireBeforeRestart()`/`fireStoryInit()` with `emit()`) +- Modify: `src/story-api.ts:1-6,390-400` (remove `onStoryInit`/`onBeforeRestart` imports, update `on()`) +- Modify: `test/unit/store.test.ts:5-6,884` (update imports) + +- [ ] **Step 1: Run existing tests to confirm green baseline** + +Run: `npx vitest run test/unit/store.test.ts test/unit/story-api.test.ts` +Expected: all pass. + +- [ ] **Step 2: Remove listener arrays and helpers from store.ts** + +In `src/store.ts`, remove the `storyinit` and `beforerestart` sections (lines 215–252: the type aliases, arrays, `onStoryInit()`, `fireStoryInit()`, `onBeforeRestart()`, `fireBeforeRestart()`). + +Add the emitter import at the top of `src/store.ts`: + +```typescript +import { emit } from './event-emitter'; +``` + +In the `restart()` method, replace `fireBeforeRestart()` (line 560) with: + +```typescript +emit('beforerestart'); +``` + +Replace `fireStoryInit()` (line 597) with: + +```typescript +emit('storyinit'); +``` + +- [ ] **Step 3: Update story-api.ts to delegate storyinit/beforerestart to emitter** + +In `src/story-api.ts`, remove `onStoryInit` and `onBeforeRestart` from the store import (lines 3–4). + +Add emitter import: + +```typescript +import { on as emitterOn } from './event-emitter'; +``` + +In the `on()` method, replace the `beforerestart` block (lines 390–394): + +```typescript +if (event === 'beforerestart') { + const unsub = emitterOn('beforerestart', callback as BeforeRestartCallback); + trackRuntimeUnsub(unsub); + return unsub; +} +``` + +Replace the `storyinit` block (lines 396–400): + +```typescript +if (event === 'storyinit') { + const unsub = emitterOn('storyinit', callback as StoryInitCallback); + trackRuntimeUnsub(unsub); + return unsub; +} +``` + +- [ ] **Step 4: Update test imports in store.test.ts** + +In `test/unit/store.test.ts`, remove `onBeforeRestart` and `onStoryInit` from the store import (lines 5–6). + +Add emitter import: + +```typescript +import { on as emitterOn } from '../../src/event-emitter'; +``` + +Replace the one direct usage of `onBeforeRestart` at line 884: + +```typescript +const unsub = emitterOn('beforerestart', () => { + useStoryStore.getState().deferRender(); +}); +``` + +- [ ] **Step 5: Run tests to verify migration** + +Run: `npx vitest run test/unit/store.test.ts test/unit/story-api.test.ts test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/store.ts src/story-api.ts test/unit/store.test.ts +git commit -m "refactor: migrate storyinit/beforerestart to event emitter (#129)" +``` + +--- + +### Task 3: Migrate `actionsChanged` from action-registry.ts to emitter + +**Files:** + +- Modify: `src/action-registry.ts:31,82-93` (remove `listeners` Set, `onActionsChanged()`, rewrite `notify()`) +- Modify: `src/story-api.ts:32,402-406` (remove `onActionsChanged` import, update `on()`) +- Modify: `test/unit/action-registry.test.ts:10,103,111,125-157` (update to use emitter) + +- [ ] **Step 1: Run existing action-registry tests to confirm green baseline** + +Run: `npx vitest run test/unit/action-registry.test.ts` +Expected: all pass. + +- [ ] **Step 2: Update action-registry.ts to use emitter** + +In `src/action-registry.ts`: + +Remove `const listeners = new Set<() => void>();` (line 31). + +Remove the `onActionsChanged` function (lines 82–87). + +Replace the `notify` function (lines 89–93) with: + +```typescript +function notify(): void { + emit('actionsChanged'); +} +``` + +Add import at the top: + +```typescript +import { emit } from './event-emitter'; +``` + +Remove the `onActionsChanged` export — it's no longer needed. + +- [ ] **Step 3: Update story-api.ts** + +Remove `onActionsChanged` from the action-registry import (line 32). + +In the `on()` method, replace the `actionsChanged` block (lines 402–406): + +```typescript +if (event === 'actionsChanged') { + const unsub = emitterOn('actionsChanged', callback as ActionsChangedCallback); + trackRuntimeUnsub(unsub); + return unsub; +} +``` + +- [ ] **Step 4: Update action-registry tests** + +In `test/unit/action-registry.test.ts`: + +Remove `onActionsChanged` from the action-registry import (line 10). + +Add emitter imports: + +```typescript +import { on as emitterOn, resetEmitter } from '../../src/event-emitter'; +``` + +Add to the `beforeEach`: + +```typescript +beforeEach(() => { + clearActions(); + resetIdCounters(); + resetEmitter(); +}); +``` + +Replace all `onActionsChanged(() => count++)` calls with `emitterOn('actionsChanged', () => count++)`. + +In the `onActionsChanged` describe block (line 125), rename it to `actionsChanged event` and update similarly — every `onActionsChanged(...)` becomes `emitterOn('actionsChanged', ...)`. + +- [ ] **Step 5: Run tests** + +Run: `npx vitest run test/unit/action-registry.test.ts test/unit/story-api.test.ts test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/action-registry.ts src/story-api.ts test/unit/action-registry.test.ts +git commit -m "refactor: migrate actionsChanged to event emitter (#129)" +``` + +--- + +### Task 4: Migrate `variableChanged` to shared Zustand subscription + +**Files:** + +- Modify: `src/story-api.ts:76-82,408-430` (shared subscription, delegate to emitter) +- Modify: `test/unit/story-api.test.ts:214-243` (update tests) + +- [ ] **Step 1: Run existing variableChanged tests to confirm green baseline** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: all pass. + +- [ ] **Step 2: Implement shared variableChanged subscription in story-api.ts** + +In `src/story-api.ts`, add a module-level shared subscription setup function after the deferred-render section (after line 74): + +```typescript +/** Lazily created shared Zustand subscription for variableChanged. */ +let variableChangedSubActive = false; + +function ensureVariableChangedSubscription(): void { + if (variableChangedSubActive) return; + variableChangedSubActive = true; + let prevVars = { ...useStoryStore.getState().variables }; + useStoryStore.subscribe((state) => { + const changed: Record = {}; + let hasChanges = false; + const allKeys = new Set([ + ...Object.keys(prevVars), + ...Object.keys(state.variables), + ]); + for (const key of allKeys) { + if (state.variables[key] !== prevVars[key]) { + changed[key] = { from: prevVars[key], to: state.variables[key] }; + hasChanges = true; + } + } + prevVars = { ...state.variables }; + if (hasChanges) { + emit('variableChanged', changed); + } + }); +} +``` + +Add `emit` to the emitter import: + +```typescript +import { on as emitterOn, emit } from './event-emitter'; +``` + +- [ ] **Step 3: Update the on() method for variableChanged** + +In `src/story-api.ts`, replace the `variableChanged` block (lines 408–430) with: + +```typescript +if (event === 'variableChanged') { + ensureVariableChangedSubscription(); + const unsub = emitterOn( + 'variableChanged', + callback as VariableChangedCallback, + ); + trackRuntimeUnsub(unsub); + return unsub; +} +``` + +- [ ] **Step 4: Update the on(variableChanged) test** + +In `test/unit/story-api.test.ts`, replace the `on(variableChanged)` describe block (lines 214–243) with a test that uses `Story.on` directly (it already does, but the current test reimplements the subscription logic — simplify it): + +```typescript +describe('on(variableChanged)', () => { + it('fires callback when variables change', () => { + const cb = vi.fn(); + const unsub = Story.on('variableChanged', cb); + + useStoryStore.getState().setVariable('gold', 50); + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ + gold: { from: undefined, to: 50 }, + }), + ); + unsub(); + }); +}); +``` + +- [ ] **Step 5: Run tests** + +Run: `npx vitest run test/unit/story-api.test.ts test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/story-api.ts test/unit/story-api.test.ts +git commit -m "refactor: migrate variableChanged to shared subscription + emitter (#129)" +``` + +--- + +### Task 5: Simplify Story.on() and remove `navigate` event + +**Files:** + +- Modify: `src/story-api.ts:76-82,376-433` (collapse switch cases, remove navigate) +- Modify: `test/unit/story-api.test.ts:197-212,360-371` (update navigate tests) + +- [ ] **Step 1: Collapse Story.on() to use emitter for all non-variableChanged events** + +In `src/story-api.ts`, replace the entire `on()` method body (lines 376–433) with: + +```typescript +on(event: string, callback: (...args: any[]) => void): () => void { + if (event === 'variableChanged') { + ensureVariableChangedSubscription(); + } + const unsub = emitterOn(event as any, callback as any); + trackRuntimeUnsub(unsub); + return unsub; +}, +``` + +The emitter's `on()` validates the event name and throws for unknowns (including `'navigate'`). + +Remove the now-unused type aliases (`NavigateCallback`, `StoryInitCallback`, `BeforeRestartCallback`, `ActionsChangedCallback`) — lines 76–79. Keep `VariableChangedCallback` (line 80–82) since it's used in the `ensureVariableChangedSubscription` function. + +- [ ] **Step 2: Update navigate tests to use afternavigate** + +In `test/unit/story-api.test.ts`, replace the `on(navigate)` describe block (lines 197–212) with: + +```typescript +describe('on(afternavigate)', () => { + it('fires callback when passage changes via navigate', () => { + const cb = vi.fn(); + const unsub = Story.on('afternavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + unsub(); + }); +}); +``` + +Note: this test will not pass yet because `navigate()` in store.ts doesn't emit `afternavigate` yet. That comes in Task 7. For now, write it but mark it with `it.skip` temporarily. + +```typescript +describe('on(afternavigate)', () => { + it.skip('fires callback when passage changes via navigate', () => { + // Enabled in Task 7 when navigate() emits afternavigate + const cb = vi.fn(); + const unsub = Story.on('afternavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + unsub(); + }); +}); +``` + +- [ ] **Step 3: Update the unknown event test** + +In `test/unit/story-api.test.ts`, replace the `on(unknown event)` describe block (lines 360–371) with: + +```typescript +describe('on(unknown event)', () => { + it('throws for unknown event', () => { + expect(() => Story.on('badEvent', () => {})).toThrow( + 'spindle: Unknown event "badEvent"', + ); + }); + + it('throws for removed navigate event', () => { + expect(() => Story.on('navigate', () => {})).toThrow( + 'spindle: Unknown event "navigate"', + ); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run test/unit/story-api.test.ts test/unit/event-emitter.test.ts` +Expected: all pass (skipped test doesn't count as failure). + +- [ ] **Step 5: Commit** + +```bash +git add src/story-api.ts test/unit/story-api.test.ts +git commit -m "refactor: simplify Story.on() to delegate to emitter, remove navigate event (#129) + +BREAKING CHANGE: Story.on('navigate') removed — use Story.on('afternavigate')" +``` + +--- + +### Task 6: Add beforesave / aftersave hooks + +**Files:** + +- Modify: `src/store.ts:611-636` (emit hooks in `save()`) +- Modify: `test/unit/store.test.ts` (add hook tests) + +- [ ] **Step 1: Write failing tests for save hooks** + +In `test/unit/store.test.ts`, add a new describe block after the existing `restart()` tests: + +```typescript +describe('save hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforesave before getSavePayload()', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + let capturedVars: Record | null = null; + on('beforesave', () => { + // Inject a variable — it should appear in the saved payload + useStoryStore.getState().setVariable('injected', 42); + capturedVars = { ...useStoryStore.getState().variables }; + }); + + useStoryStore.getState().save('test-slot'); + expect(capturedVars).toEqual({ injected: 42 }); + }); + + it('beforesave receives slot and custom args', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('beforesave', cb); + + useStoryStore.getState().save('slot-1', { meta: true }); + expect(cb).toHaveBeenCalledWith('slot-1', { meta: true }); + }); + + it('beforesave receives undefined for default slot', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('beforesave', cb); + + useStoryStore.getState().save(); + expect(cb).toHaveBeenCalledWith(undefined, undefined); + }); +}); +``` + +Add the emitter import to the test file's imports: + +```typescript +import { on, resetEmitter } from '../../src/event-emitter'; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store.test.ts -t "save hooks"` +Expected: FAIL — `beforesave` is never emitted. + +- [ ] **Step 3: Add beforesave/aftersave emit calls in store.ts save()** + +In `src/store.ts`, modify the `save()` method (lines 611–636). Add `emit('beforesave', slot, custom)` before `getSavePayload()` and `emit('aftersave', slot)` inside `.then()`: + +```typescript +save: (slot?: string, custom?: Record) => { + const { storyData, playthroughId } = get(); + if (!storyData) return; + + emit('beforesave', slot, custom); + + const payload = get().getSavePayload(); + + set((state) => { + state.saveError = null; + }); + quickSave(storyData.ifid, playthroughId, payload, slot, custom) + .then(() => { + set((state) => { + state.knownSaves = { + ...state.knownSaves, + [slot ?? '']: true, + }; + }); + emit('aftersave', slot); + }) + .catch((err) => { + console.error('spindle: failed to save', err); + set((state) => { + state.saveError = + err instanceof Error ? err.message : 'Failed to save'; + }); + }); +}, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/store.test.ts -t "save hooks"` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/store.ts test/unit/store.test.ts +git commit -m "feat: add beforesave/aftersave hooks (#129)" +``` + +--- + +### Task 7: Add beforenavigate / afternavigate hooks + +**Files:** + +- Modify: `src/store.ts:416-514` (emit hooks in `navigate()`, `goBack()`, `goForward()`) +- Modify: `test/unit/store.test.ts` (add navigate hook tests) +- Modify: `test/unit/story-api.test.ts` (unskip afternavigate test) + +- [ ] **Step 1: Write failing tests for navigate hooks** + +In `test/unit/store.test.ts`, add a new describe block: + +```typescript +describe('navigate hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforenavigate before state change', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let passageDuringHook: string | null = null; + on('beforenavigate', () => { + passageDuringHook = useStoryStore.getState().currentPassage; + }); + + useStoryStore.getState().navigate('Room'); + expect(passageDuringHook).toBe('Start'); + }); + + it('beforenavigate receives target passage name', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('beforenavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room'); + }); + + it('emits afternavigate after state change', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let passageDuringHook: string | null = null; + on('afternavigate', () => { + passageDuringHook = useStoryStore.getState().currentPassage; + }); + + useStoryStore.getState().navigate('Room'); + expect(passageDuringHook).toBe('Room'); + }); + + it('afternavigate receives (to, from)', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('afternavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('hooks fire on goBack()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + useStoryStore.getState().navigate('Room'); + + const beforeCb = vi.fn(); + const afterCb = vi.fn(); + on('beforenavigate', beforeCb); + on('afternavigate', afterCb); + + useStoryStore.getState().goBack(); + expect(beforeCb).toHaveBeenCalledWith('Start'); + expect(afterCb).toHaveBeenCalledWith('Start', 'Room'); + }); + + it('hooks fire on goForward()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + useStoryStore.getState().navigate('Room'); + useStoryStore.getState().goBack(); + + const beforeCb = vi.fn(); + const afterCb = vi.fn(); + on('beforenavigate', beforeCb); + on('afternavigate', afterCb); + + useStoryStore.getState().goForward(); + expect(beforeCb).toHaveBeenCalledWith('Room'); + expect(afterCb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('hooks do NOT fire on loadFromPayload()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('beforenavigate', cb); + on('afternavigate', cb); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: {}, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: {}, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('no hooks fire for invalid passage', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + on('beforenavigate', cb); + on('afternavigate', cb); + + useStoryStore.getState().navigate('Nonexistent'); + expect(cb).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store.test.ts -t "navigate hooks"` +Expected: FAIL — hooks are never emitted. + +- [ ] **Step 3: Add emit calls in navigate()** + +In `src/store.ts`, modify `navigate()` (lines 416–472). Capture `previousPassage` before the state change, emit `beforenavigate` after validation, emit `afternavigate` at the end: + +```typescript +navigate: (passageName: string) => { + const { storyData, variables: currVars } = get(); + if (!storyData) return; + + if (SPECIAL_PASSAGES.has(passageName)) { + console.error( + `spindle: Cannot navigate to special passage "${passageName}".`, + ); + return; + } + + if (!storyData.passages.has(passageName)) { + console.error(`spindle: Passage "${passageName}" not found.`); + return; + } + + const previousPassage = get().currentPassage; + emit('beforenavigate', passageName); + + // Compute variable delta before Immer set() + const patchEntry = computeVarPatches(lastNavigationVars, currVars); + + set((state) => { + state.temporary = {}; + state.currentPassage = passageName; + + // Truncate forward history if we navigated back then chose a new path + state.history = state.history.slice(0, state.historyIndex + 1); + patchEntries.length = state.historyIndex; + + // Push new transition and moment + patchEntries.push(patchEntry); + state.history.push({ + passage: passageName, + timestamp: Date.now(), + prng: snapshotPRNG(), + }); + + // Trim oldest entries if over the limit + const overflow = state.history.length - state.maxHistory; + if (overflow > 0) { + // Advance base through trimmed transitions + for (let i = 0; i < overflow; i++) { + variableBase = applyPatches(variableBase, patchEntries[i]!.forward); + } + state.history = state.history.slice(overflow); + patchEntries = patchEntries.slice(overflow); + serializedHistory = serializedHistory.slice(overflow); + } + + state.historyIndex = state.history.length - 1; + state.visitCounts[passageName] = + (state.visitCounts[passageName] ?? 0) + 1; + state.renderCounts[passageName] = + (state.renderCounts[passageName] ?? 0) + 1; + }); + + lastNavigationVars = get().variables; + persistSession(get); + + emit('afternavigate', passageName, previousPassage); +}, +``` + +- [ ] **Step 4: Add emit calls in goBack()** + +In `src/store.ts`, modify `goBack()` (lines 474–493): + +```typescript +goBack: () => { + const { historyIndex, variables } = get(); + if (historyIndex <= 0) return; + + const previousPassage = get().currentPassage; + const targetPassage = get().history[historyIndex - 1]!.passage; + emit('beforenavigate', targetPassage); + + // Apply inverse transition: moment historyIndex → historyIndex−1 + const restoredVars = deepClone( + applyPatches(variables, patchEntries[historyIndex - 1]!.inverse), + ); + + set((state) => { + state.historyIndex--; + state.currentPassage = state.history[state.historyIndex]!.passage; + state.variables = restoredVars; + state.temporary = {}; + }); + + lastNavigationVars = get().variables; + restorePRNGFromMoment(get().history[get().historyIndex]); + persistSession(get); + + emit('afternavigate', targetPassage, previousPassage); +}, +``` + +- [ ] **Step 5: Add emit calls in goForward()** + +In `src/store.ts`, modify `goForward()` (lines 495–514): + +```typescript +goForward: () => { + const { historyIndex, history: hist, variables } = get(); + if (historyIndex >= hist.length - 1) return; + + const previousPassage = get().currentPassage; + const targetPassage = hist[historyIndex + 1]!.passage; + emit('beforenavigate', targetPassage); + + // Apply forward transition: moment historyIndex → historyIndex+1 + const restoredVars = deepClone( + applyPatches(variables, patchEntries[historyIndex]!.forward), + ); + + set((state) => { + state.historyIndex++; + state.currentPassage = state.history[state.historyIndex]!.passage; + state.variables = restoredVars; + state.temporary = {}; + }); + + lastNavigationVars = get().variables; + restorePRNGFromMoment(get().history[get().historyIndex]); + persistSession(get); + + emit('afternavigate', targetPassage, previousPassage); +}, +``` + +- [ ] **Step 6: Unskip the afternavigate test in story-api.test.ts** + +In `test/unit/story-api.test.ts`, change `it.skip` to `it` in the `on(afternavigate)` describe block added in Task 5. + +- [ ] **Step 7: Run tests** + +Run: `npx vitest run test/unit/store.test.ts test/unit/story-api.test.ts test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/store.ts test/unit/store.test.ts test/unit/story-api.test.ts +git commit -m "feat: add beforenavigate/afternavigate hooks (#129)" +``` + +--- + +### Task 8: Add beforeload / afterload hooks + +**Files:** + +- Modify: `src/store.ts:638-657` (emit hooks in `load()`) +- Modify: `test/unit/store.test.ts` (add load hook tests) + +- [ ] **Step 1: Write failing tests for load hooks** + +In `test/unit/store.test.ts`, add a new describe block: + +```typescript +describe('load hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforeload/afterload around loadFromPayload', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const order: string[] = []; + on('beforeload', () => order.push('beforeload')); + on('afterload', () => order.push('afterload')); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: { gold: 100 }, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: { gold: 100 }, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(order).toEqual(['beforeload', 'afterload']); + }); + + it('afterload fires after state is restored', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let restoredGold: unknown = null; + on('afterload', () => { + restoredGold = useStoryStore.getState().variables.gold; + }); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: { gold: 100 }, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: { gold: 100 }, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(restoredGold).toBe(100); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store.test.ts -t "load hooks"` +Expected: FAIL — hooks are never emitted. + +- [ ] **Step 3: Add emit calls in loadFromPayload()** + +The `load()` method is async (calls `loadQuickSave().then()`), but `loadFromPayload()` is synchronous and is the actual state restoration point. The hooks should fire in `loadFromPayload()` to also cover direct `loadFromPayload()` calls (e.g., session restore). + +In `src/store.ts`, modify `loadFromPayload()` (starts at line 778). Add `emit('beforeload')` at the top and `emit('afterload')` at the end: + +```typescript +loadFromPayload: (payload: SavePayload) => { + if (payload.history.length === 0) { + console.warn('loadFromPayload: rejecting payload with empty history'); + return; + } + + emit('beforeload', undefined); + + // Convert full snapshots to patch entries + const base = deserialize(payload.history[0]?.variables ?? {}) as Record< + string, + unknown + >; + const newPatchEntries: PatchEntry[] = []; + + let prevVars: Record = base; + for (let i = 1; i < payload.history.length; i++) { + const currVars = deserialize(payload.history[i]!.variables) as Record< + string, + unknown + >; + newPatchEntries.push(computeVarPatches(prevVars, currVars)); + prevVars = currVars; + } + + variableBase = deepClone(base); + patchEntries = newPatchEntries; + serializedHistory = []; + + set((state) => { + state.currentPassage = payload.passage; + state.variables = deserialize(payload.variables) as Record< + string, + unknown + >; + state.history = payload.history.map((m) => ({ + passage: m.passage, + timestamp: m.timestamp, + prng: m.prng, + })); + state.historyIndex = Math.max( + 0, + Math.min(payload.historyIndex, state.history.length - 1), + ); + state.visitCounts = payload.visitCounts ?? {}; + state.renderCounts = payload.renderCounts ?? {}; + state.temporary = {}; + }); + + lastNavigationVars = get().variables; + + if (payload.prng) { + restorePRNG(payload.prng.seed, payload.prng.pull); + } else { + resetPRNG(); + } + + emit('afterload', undefined); +}, +``` + +Note: `loadFromPayload` doesn't know the slot name — it receives a `SavePayload` directly. The slot argument is `undefined` here. The `load()` method that wraps it could pass the slot through, but `loadFromPayload` is also called by session restore which has no slot. Using `undefined` keeps the API honest. + +- [ ] **Step 4: Update the navigate hooks loadFromPayload test** + +The earlier test in Task 7 for "hooks do NOT fire on loadFromPayload" tested that navigate hooks don't fire. That test should still pass because load hooks fire but navigate hooks don't. Double-check this test is still valid. + +- [ ] **Step 5: Run tests** + +Run: `npx vitest run test/unit/store.test.ts test/unit/event-emitter.test.ts` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/store.ts test/unit/store.test.ts +git commit -m "feat: add beforeload/afterload hooks (#129)" +``` + +--- + +### Task 9: Add auto-cleanup integration tests + +**Files:** + +- Modify: `test/unit/store.test.ts` (add runtime cleanup tests for new hooks) + +- [ ] **Step 1: Write tests for auto-cleanup of new hooks** + +In `test/unit/store.test.ts`, add tests inside the existing `runtime handler cleanup` describe block: + +```typescript +it('runtime-registered hook unsubs are called on restart', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + enterRuntimePhase(); + + const cb = vi.fn(); + const unsub = on('beforesave', cb); + trackRuntimeUnsub(unsub); + + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); + + useStoryStore.getState().restart(); + + cb.mockClear(); + useStoryStore.getState().save('test'); + expect(cb).not.toHaveBeenCalled(); +}); + +it('startup-registered hooks survive restart', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + // Register BEFORE entering runtime phase + const cb = vi.fn(); + on('beforesave', cb); + + enterRuntimePhase(); + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); + + useStoryStore.getState().restart(); + + cb.mockClear(); + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run test/unit/store.test.ts -t "runtime handler cleanup"` +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add test/unit/store.test.ts +git commit -m "test: add auto-cleanup integration tests for new hooks (#129)" +``` + +--- + +### Task 10: Run full test suite and clean up + +**Files:** + +- Possibly modify: any files with remaining issues + +- [ ] **Step 1: Run the full test suite** + +Run: `npx vitest run` +Expected: all pass. + +- [ ] **Step 2: Run type check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Clean up any remaining dead imports** + +Check that no file still imports `onStoryInit`, `onBeforeRestart`, or `onActionsChanged`. These were removed in Tasks 2–3. + +Run: `grep -r "onStoryInit\|onBeforeRestart\|onActionsChanged" src/ test/` + +Expected: no matches. + +- [ ] **Step 4: Commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore: clean up dead imports after event emitter migration (#129)" +``` From 048ea00010c6055a9d3bc088f7d58336532d639f Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:07:07 +0800 Subject: [PATCH 03/12] feat: add centralized event emitter module (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/event-emitter.ts | 71 +++++++++++++++++++++++++++++ test/unit/event-emitter.test.ts | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/event-emitter.ts create mode 100644 test/unit/event-emitter.test.ts diff --git a/src/event-emitter.ts b/src/event-emitter.ts new file mode 100644 index 0000000..7fd5622 --- /dev/null +++ b/src/event-emitter.ts @@ -0,0 +1,71 @@ +type EventMap = { + storyinit: () => void; + beforerestart: () => void; + actionsChanged: () => void; + variableChanged: ( + changed: Record, + ) => void; + beforesave: ( + slot: string | undefined, + custom: Record | undefined, + ) => void; + aftersave: (slot: string | undefined) => void; + beforeload: (slot: string | undefined) => void; + afterload: (slot: string | undefined) => void; + beforenavigate: (passageName: string) => void; + afternavigate: (to: string, from: string) => void; +}; + +export type StoryEvent = keyof EventMap; +export type StoryEventCallback = EventMap[E]; + +const VALID_EVENTS = new Set([ + 'storyinit', + 'beforerestart', + 'actionsChanged', + 'variableChanged', + 'beforesave', + 'aftersave', + 'beforeload', + 'afterload', + 'beforenavigate', + 'afternavigate', +]); + +// Each event key maps to a Set of callbacks. +let listeners = new Map>(); + +export function on( + event: E, + cb: EventMap[E], +): () => void { + if (!VALID_EVENTS.has(event)) { + throw new Error(`spindle: Unknown event "${event}".`); + } + let set = listeners.get(event); + if (!set) { + set = new Set(); + listeners.set(event, set); + } + set.add(cb); + return () => { + set!.delete(cb); + }; +} + +export function emit( + event: E, + ...args: Parameters +): void { + const set = listeners.get(event); + if (!set) return; + // Snapshot to tolerate unsubscription during iteration + for (const cb of [...set]) { + (cb as Function)(...args); + } +} + +/** Test-only: clear all listeners. */ +export function resetEmitter(): void { + listeners = new Map(); +} diff --git a/test/unit/event-emitter.test.ts b/test/unit/event-emitter.test.ts new file mode 100644 index 0000000..977b25f --- /dev/null +++ b/test/unit/event-emitter.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { on, emit, resetEmitter } from '../../src/event-emitter'; + +describe('event-emitter', () => { + beforeEach(() => { + resetEmitter(); + }); + + it('on() returns an unsub that removes the listener', () => { + const cb = vi.fn(); + const unsub = on('storyinit', cb); + emit('storyinit'); + expect(cb).toHaveBeenCalledTimes(1); + unsub(); + emit('storyinit'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('emit() calls listeners in registration order', () => { + const order: number[] = []; + on('beforerestart', () => order.push(1)); + on('beforerestart', () => order.push(2)); + on('beforerestart', () => order.push(3)); + emit('beforerestart'); + expect(order).toEqual([1, 2, 3]); + }); + + it('emit() passes arguments to listeners', () => { + const cb = vi.fn(); + on('beforesave', cb); + emit('beforesave', 'slot-1', { meta: true }); + expect(cb).toHaveBeenCalledWith('slot-1', { meta: true }); + }); + + it('emit() passes undefined args correctly', () => { + const cb = vi.fn(); + on('beforesave', cb); + emit('beforesave', undefined, undefined); + expect(cb).toHaveBeenCalledWith(undefined, undefined); + }); + + it('unsubscribing during emit does not skip listeners', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + let unsub1: () => void; + unsub1 = on('storyinit', () => { + cb1(); + unsub1(); + }); + on('storyinit', cb2); + emit('storyinit'); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('on() throws for unknown event names', () => { + expect(() => on('badEvent' as any, vi.fn())).toThrow( + 'spindle: Unknown event "badEvent"', + ); + }); + + it('emit() is a no-op for events with no listeners', () => { + expect(() => emit('storyinit')).not.toThrow(); + }); + + it('afternavigate passes (to, from) args', () => { + const cb = vi.fn(); + on('afternavigate', cb); + emit('afternavigate', 'Room', 'Start'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('resetEmitter() clears all listeners', () => { + const cb = vi.fn(); + on('storyinit', cb); + resetEmitter(); + emit('storyinit'); + expect(cb).not.toHaveBeenCalled(); + }); +}); From 82a339cb8ecdb35356bde8df2dd860006ddc01e7 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:12:40 +0800 Subject: [PATCH 04/12] refactor: migrate storyinit/beforerestart to event emitter (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/index.tsx | 5 +++-- src/store.ts | 44 +++-------------------------------------- src/story-api.ts | 15 +++++++------- test/unit/store.test.ts | 5 ++--- 4 files changed, 15 insertions(+), 54 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index c42485c..8f756e3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,8 @@ import { render } from 'preact'; import { App } from './components/App'; import { parseStoryData } from './parser'; -import { useStoryStore, fireStoryInit, enterRuntimePhase } from './store'; +import { useStoryStore, enterRuntimePhase } from './store'; +import { emit } from './event-emitter'; import { installStoryAPI, getReadyPromise } from './story-api'; import { resetIdCounters } from './action-registry'; import { executeStoryInit } from './story-init'; @@ -112,7 +113,7 @@ function boot() { } // Fire storyinit after all state is settled (defaults + StoryInit + session) - fireStoryInit(); + emit('storyinit'); // Pass 1: Pre-scan all widget passages to discover block widgets. // Register them as block macros BEFORE any tokenize/buildAST calls, diff --git a/src/store.ts b/src/store.ts index a2eaa90..f334335 100644 --- a/src/store.ts +++ b/src/store.ts @@ -10,6 +10,7 @@ import type { StoryData } from './parser'; import type { TransitionConfig } from './transition'; import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types'; import { executeStoryInit } from './story-init'; +import { emit } from './event-emitter'; import { resetTriggers } from './triggers'; import { initSaveSystem, @@ -212,45 +213,6 @@ export function _resetRuntimePhase(): void { inRuntimePhase = false; } -// --------------------------------------------------------------------------- -// storyinit callbacks (direct invocation — avoids Zustand subscription issues) -// --------------------------------------------------------------------------- - -type StoryInitListener = () => void; -let storyInitListeners: StoryInitListener[] = []; - -/** Register a callback to run after StoryInit completes (boot + every restart). */ -export function onStoryInit(cb: StoryInitListener): () => void { - storyInitListeners.push(cb); - return () => { - storyInitListeners = storyInitListeners.filter((l) => l !== cb); - }; -} - -/** Fire all storyinit listeners. Called after all state resets are complete. */ -export function fireStoryInit(): void { - for (const cb of storyInitListeners) cb(); -} - -// --------------------------------------------------------------------------- -// beforerestart callbacks -// --------------------------------------------------------------------------- - -type BeforeRestartListener = () => void; -let beforeRestartListeners: BeforeRestartListener[] = []; - -/** Register a callback to run before restart resets any state. */ -export function onBeforeRestart(cb: BeforeRestartListener): () => void { - beforeRestartListeners.push(cb); - return () => { - beforeRestartListeners = beforeRestartListeners.filter((l) => l !== cb); - }; -} - -function fireBeforeRestart(): void { - for (const cb of beforeRestartListeners) cb(); -} - // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -557,7 +519,7 @@ export const useStoryStore = create()( state.renderDeferred = false; }); - fireBeforeRestart(); + emit('beforerestart'); const keepDeferred = get().renderDeferred; @@ -594,7 +556,7 @@ export const useStoryStore = create()( executeStoryInit(); clearSession(storyData.ifid); - fireStoryInit(); + emit('storyinit'); // Start a new playthrough on restart startNewPlaythrough(storyData.ifid) diff --git a/src/story-api.ts b/src/story-api.ts index dcafdb0..f851444 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -1,9 +1,5 @@ -import { - useStoryStore, - onStoryInit, - onBeforeRestart, - trackRuntimeUnsub, -} from './store'; +import { useStoryStore, trackRuntimeUnsub } from './store'; +import { on as emitterOn } from './event-emitter'; import type { Passage } from './parser'; import { settings } from './settings'; import type { @@ -388,13 +384,16 @@ function createStoryAPI(): StoryAPI { } if (event === 'beforerestart') { - const unsub = onBeforeRestart(callback as BeforeRestartCallback); + const unsub = emitterOn( + 'beforerestart', + callback as BeforeRestartCallback, + ); trackRuntimeUnsub(unsub); return unsub; } if (event === 'storyinit') { - const unsub = onStoryInit(callback as StoryInitCallback); + const unsub = emitterOn('storyinit', callback as StoryInitCallback); trackRuntimeUnsub(unsub); return unsub; } diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 84de05c..8e111af 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -2,12 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useStoryStore, - onBeforeRestart, - onStoryInit, trackRuntimeUnsub, enterRuntimePhase, _resetRuntimePhase, } from '../../src/store'; +import { on as emitterOn } from '../../src/event-emitter'; import { executeStoryInit } from '../../src/story-init'; import type { StoryData, Passage } from '../../src/parser'; import { registerClass, clearRegistry } from '../../src/class-registry'; @@ -881,7 +880,7 @@ describe('useStoryStore', () => { it('restart() preserves renderDeferred if set during beforerestart', () => { const story = makeStoryData([makePassage(1, 'Start', '')]); useStoryStore.getState().init(story); - const unsub = onBeforeRestart(() => { + const unsub = emitterOn('beforerestart', () => { useStoryStore.getState().deferRender(); }); useStoryStore.getState().restart(); From fab0fbbd32dc1ad4ad55fe1a0a8dafe01e22c9ff Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:17:07 +0800 Subject: [PATCH 05/12] refactor: migrate actionsChanged to event emitter (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/action-registry.ts | 14 +++----------- src/story-api.ts | 12 +++++------- test/unit/action-registry.test.ts | 17 +++++++++-------- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/action-registry.ts b/src/action-registry.ts index 22d7970..bdf824c 100644 --- a/src/action-registry.ts +++ b/src/action-registry.ts @@ -1,3 +1,5 @@ +import { emit } from './event-emitter'; + export type ActionType = | 'link' | 'button' @@ -28,7 +30,6 @@ export interface StoryAction { } const actions = new Map(); -const listeners = new Set<() => void>(); const idCounters = new Map(); export function generateActionId( @@ -79,15 +80,6 @@ export function resetIdCounters(): void { idCounters.clear(); } -export function onActionsChanged(fn: () => void): () => void { - listeners.add(fn); - return () => { - listeners.delete(fn); - }; -} - function notify(): void { - for (const fn of listeners) { - fn(); - } + emit('actionsChanged'); } diff --git a/src/story-api.ts b/src/story-api.ts index f851444..6579b6f 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -22,12 +22,7 @@ import { defineMacro } from './define-macro'; import type { MacroDefinition } from './define-macro'; import { getMacroRegistry as _getMacroRegistry } from './registry'; import type { MacroMetadata } from './registry'; -import { - getActions, - getAction, - onActionsChanged, - type StoryAction, -} from './action-registry'; +import { getActions, getAction, type StoryAction } from './action-registry'; import { initPRNG, isPRNGEnabled, @@ -399,7 +394,10 @@ function createStoryAPI(): StoryAPI { } if (event === 'actionsChanged') { - const unsub = onActionsChanged(callback as ActionsChangedCallback); + const unsub = emitterOn( + 'actionsChanged', + callback as ActionsChangedCallback, + ); trackRuntimeUnsub(unsub); return unsub; } diff --git a/test/unit/action-registry.test.ts b/test/unit/action-registry.test.ts index 6a4b880..84f8b3a 100644 --- a/test/unit/action-registry.test.ts +++ b/test/unit/action-registry.test.ts @@ -7,9 +7,9 @@ import { clearActions, resetIdCounters, generateActionId, - onActionsChanged, type StoryAction, } from '../../src/action-registry'; +import { on as emitterOn, resetEmitter } from '../../src/event-emitter'; function makeAction(overrides: Partial = {}): StoryAction { return { @@ -25,6 +25,7 @@ describe('action-registry', () => { beforeEach(() => { clearActions(); resetIdCounters(); + resetEmitter(); }); describe('registerAction / getActions / getAction', () => { @@ -100,7 +101,7 @@ describe('action-registry', () => { it('updates an existing action with a single notify', () => { let count = 0; registerAction(makeAction({ id: 'a', label: 'first' })); - onActionsChanged(() => count++); + emitterOn('actionsChanged', () => count++); updateAction(makeAction({ id: 'a', label: 'second' })); expect(count).toBe(1); // single notify, not 2 (delete+set) expect(getAction('a')!.label).toBe('second'); @@ -108,7 +109,7 @@ describe('action-registry', () => { it('registers new action if not already present', () => { let count = 0; - onActionsChanged(() => count++); + emitterOn('actionsChanged', () => count++); updateAction(makeAction({ id: 'new', label: 'fresh' })); expect(count).toBe(1); expect(getAction('new')!.label).toBe('fresh'); @@ -122,10 +123,10 @@ describe('action-registry', () => { }); }); - describe('onActionsChanged', () => { + describe('actionsChanged event', () => { it('notifies listeners on register', () => { let count = 0; - onActionsChanged(() => count++); + emitterOn('actionsChanged', () => count++); registerAction(makeAction({ id: 'a' })); expect(count).toBe(1); }); @@ -133,7 +134,7 @@ describe('action-registry', () => { it('notifies listeners on unregister', () => { let count = 0; const unsub = registerAction(makeAction({ id: 'a' })); - onActionsChanged(() => count++); + emitterOn('actionsChanged', () => count++); unsub(); expect(count).toBe(1); }); @@ -141,14 +142,14 @@ describe('action-registry', () => { it('notifies listeners on clear', () => { let count = 0; registerAction(makeAction({ id: 'a' })); - onActionsChanged(() => count++); + emitterOn('actionsChanged', () => count++); clearActions(); expect(count).toBe(1); }); it('stops notifying after unsubscribe', () => { let count = 0; - const unsub = onActionsChanged(() => count++); + const unsub = emitterOn('actionsChanged', () => count++); registerAction(makeAction({ id: 'a' })); expect(count).toBe(1); unsub(); From 16e79c76e4db34d76e74eb8bf9476799c24699ea Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:19:16 +0800 Subject: [PATCH 06/12] refactor: migrate variableChanged to shared subscription + emitter (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/story-api.ts | 53 +++++++++++++++++++++++-------------- test/unit/story-api.test.ts | 18 +------------ 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/story-api.ts b/src/story-api.ts index 6579b6f..a88de8d 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -1,5 +1,5 @@ import { useStoryStore, trackRuntimeUnsub } from './store'; -import { on as emitterOn } from './event-emitter'; +import { on as emitterOn, emit } from './event-emitter'; import type { Passage } from './parser'; import { settings } from './settings'; import type { @@ -64,6 +64,33 @@ export function _resetReadyState(): void { readyPromise = null; } +/** Lazily created shared Zustand subscription for variableChanged. */ +let variableChangedSubActive = false; + +function ensureVariableChangedSubscription(): void { + if (variableChangedSubActive) return; + variableChangedSubActive = true; + let prevVars = { ...useStoryStore.getState().variables }; + useStoryStore.subscribe((state) => { + const changed: Record = {}; + let hasChanges = false; + const allKeys = new Set([ + ...Object.keys(prevVars), + ...Object.keys(state.variables), + ]); + for (const key of allKeys) { + if (state.variables[key] !== prevVars[key]) { + changed[key] = { from: prevVars[key], to: state.variables[key] }; + hasChanges = true; + } + } + prevVars = { ...state.variables }; + if (hasChanges) { + emit('variableChanged', changed); + } + }); +} + type NavigateCallback = (to: string, from: string) => void; type StoryInitCallback = () => void; type BeforeRestartCallback = () => void; @@ -403,25 +430,11 @@ function createStoryAPI(): StoryAPI { } if (event === 'variableChanged') { - let prevVars = { ...useStoryStore.getState().variables }; - const unsub = useStoryStore.subscribe((state) => { - const changed: Record = {}; - let hasChanges = false; - const allKeys = new Set([ - ...Object.keys(prevVars), - ...Object.keys(state.variables), - ]); - for (const key of allKeys) { - if (state.variables[key] !== prevVars[key]) { - changed[key] = { from: prevVars[key], to: state.variables[key] }; - hasChanges = true; - } - } - prevVars = { ...state.variables }; - if (hasChanges) { - (callback as VariableChangedCallback)(changed); - } - }); + ensureVariableChangedSubscription(); + const unsub = emitterOn( + 'variableChanged', + callback as VariableChangedCallback, + ); trackRuntimeUnsub(unsub); return unsub; } diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index 2416cc9..b558354 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -214,23 +214,7 @@ describe('StoryAPI', () => { describe('on(variableChanged)', () => { it('fires callback when variables change', () => { const cb = vi.fn(); - let prevVars = { ...useStoryStore.getState().variables }; - const unsub = useStoryStore.subscribe((state) => { - const changed: Record = {}; - let hasChanges = false; - const allKeys = new Set([ - ...Object.keys(prevVars), - ...Object.keys(state.variables), - ]); - for (const key of allKeys) { - if (state.variables[key] !== prevVars[key]) { - changed[key] = { from: prevVars[key], to: state.variables[key] }; - hasChanges = true; - } - } - prevVars = { ...state.variables }; - if (hasChanges) cb(changed); - }); + const unsub = Story.on('variableChanged', cb); useStoryStore.getState().setVariable('gold', 50); expect(cb).toHaveBeenCalledWith( From 583c317f4e94108d763644bba0febad81ab6ea0f Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:22:58 +0800 Subject: [PATCH 07/12] refactor: simplify Story.on() to delegate to emitter, remove navigate event (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Story.on('navigate') removed — use Story.on('afternavigate') --- src/story-api.ts | 62 +++------------------------------ test/unit/story-api.test.ts | 69 +++++++++++++++++-------------------- 2 files changed, 35 insertions(+), 96 deletions(-) diff --git a/src/story-api.ts b/src/story-api.ts index a88de8d..c863d9e 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -91,14 +91,6 @@ function ensureVariableChangedSubscription(): void { }); } -type NavigateCallback = (to: string, from: string) => void; -type StoryInitCallback = () => void; -type BeforeRestartCallback = () => void; -type ActionsChangedCallback = () => void; -type VariableChangedCallback = ( - changed: Record, -) => void; - export interface StoryAPI { get(name: string): unknown; set(name: string, value: unknown): void; @@ -142,11 +134,7 @@ export interface StoryAPI { }; getActions(): StoryAction[]; performAction(id: string, value?: unknown): void; - on(event: 'navigate', callback: NavigateCallback): () => void; - on(event: 'beforerestart', callback: BeforeRestartCallback): () => void; - on(event: 'storyinit', callback: StoryInitCallback): () => void; - on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void; - on(event: 'variableChanged', callback: VariableChangedCallback): () => void; + on(event: string, callback: (...args: any[]) => void): () => void; waitForActions(): Promise; watch( condition: string, @@ -392,54 +380,12 @@ function createStoryAPI(): StoryAPI { }, on(event: string, callback: (...args: any[]) => void): () => void { - if (event === 'navigate') { - let prev = useStoryStore.getState().currentPassage; - const unsub = useStoryStore.subscribe((state) => { - if (state.currentPassage !== prev) { - const from = prev; - prev = state.currentPassage; - (callback as NavigateCallback)(state.currentPassage, from); - } - }); - trackRuntimeUnsub(unsub); - return unsub; - } - - if (event === 'beforerestart') { - const unsub = emitterOn( - 'beforerestart', - callback as BeforeRestartCallback, - ); - trackRuntimeUnsub(unsub); - return unsub; - } - - if (event === 'storyinit') { - const unsub = emitterOn('storyinit', callback as StoryInitCallback); - trackRuntimeUnsub(unsub); - return unsub; - } - - if (event === 'actionsChanged') { - const unsub = emitterOn( - 'actionsChanged', - callback as ActionsChangedCallback, - ); - trackRuntimeUnsub(unsub); - return unsub; - } - if (event === 'variableChanged') { ensureVariableChangedSubscription(); - const unsub = emitterOn( - 'variableChanged', - callback as VariableChangedCallback, - ); - trackRuntimeUnsub(unsub); - return unsub; } - - throw new Error(`spindle: Unknown event "${event}".`); + const unsub = emitterOn(event as any, callback as any); + trackRuntimeUnsub(unsub); + return unsub; }, waitForActions(): Promise { diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index b558354..c706bf5 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -194,16 +194,11 @@ describe('StoryAPI', () => { }); }); - describe('on(navigate)', () => { - it('fires callback when passage changes', () => { + describe('on(afternavigate)', () => { + it.skip('fires callback when passage changes via navigate', () => { + // Enabled in Task 7 when navigate() emits afternavigate const cb = vi.fn(); - let prev = useStoryStore.getState().currentPassage; - const unsub = useStoryStore.subscribe((state) => { - if (state.currentPassage !== prev) { - cb(state.currentPassage, prev); - prev = state.currentPassage; - } - }); + const unsub = Story.on('afternavigate', cb); useStoryStore.getState().navigate('Room'); expect(cb).toHaveBeenCalledWith('Room', 'Start'); @@ -343,14 +338,15 @@ describe('StoryAPI', () => { describe('on(unknown event)', () => { it('throws for unknown event', () => { - expect(() => { - const event = 'badEvent'; - if ( - !['navigate', 'actionsChanged', 'variableChanged'].includes(event) - ) { - throw new Error(`spindle: Unknown event "${event}".`); - } - }).toThrow('Unknown event'); + expect(() => Story.on('badEvent', () => {})).toThrow( + 'spindle: Unknown event "badEvent"', + ); + }); + + it('throws for removed navigate event', () => { + expect(() => Story.on('navigate', () => {})).toThrow( + 'spindle: Unknown event "navigate"', + ); }); }); @@ -607,23 +603,20 @@ describe('StoryAPI', () => { _resetRuntimePhase(); }); - it('navigate handler registered during runtime is cleaned on restart', () => { + it('beforerestart handler registered during runtime is cleaned on restart', () => { enterRuntimePhase(); const cb = vi.fn(); - Story.on('navigate', cb); + Story.on('beforerestart', cb); - // Navigate to verify handler works - Story.goto('Room'); - expect(cb).toHaveBeenCalledWith('Room', 'Start'); + // First restart: handler fires (beforerestart fires before cleanup) + Story.restart(); + expect(cb).toHaveBeenCalledTimes(1); cb.mockClear(); - // Restart cleans the handler + // Second restart: handler was cleaned, should NOT fire Story.restart(); - - // Navigate again — handler should NOT fire - Story.goto('Room'); expect(cb).not.toHaveBeenCalled(); }); @@ -651,40 +644,40 @@ describe('StoryAPI', () => { // BEFORE entering runtime phase (host boot runs during startup) Story.on('storyinit', () => { // Inside storyinit, we're in runtime phase (restart calls enterRuntimePhase) - Story.on('navigate', () => { - calls.push('nav'); + Story.on('beforerestart', () => { + calls.push('before'); }); }); enterRuntimePhase(); - // First restart: storyinit fires, registers one navigate handler + // First restart: storyinit fires, registers one beforerestart handler Story.restart(); - Story.goto('Room'); - expect(calls).toEqual(['nav']); + // Second restart: beforerestart fires once (one handler), then cleaned, storyinit re-registers + Story.restart(); + expect(calls).toEqual(['before']); calls.length = 0; - // Second restart: old navigate handler cleaned, storyinit registers a new one + // Third restart: still exactly one handler, not accumulating duplicates Story.restart(); - Story.goto('Room'); - expect(calls).toEqual(['nav']); // still exactly one, not two + expect(calls).toEqual(['before']); // still exactly one, not two }); it('manual unsub still works and double-call is safe', () => { enterRuntimePhase(); const cb = vi.fn(); - const unsub = Story.on('navigate', cb); + const unsub = Story.on('storyinit', cb); // Manually unsub unsub(); - // Navigate — should not fire - Story.goto('Room'); + // Restart — handler should not fire + Story.restart(); expect(cb).not.toHaveBeenCalled(); - // Restart — the stale entry in runtimeUnsubs is a no-op + // Second restart — the stale entry in runtimeUnsubs is a no-op expect(() => Story.restart()).not.toThrow(); }); }); From 907f4b58f7be72f23996a1134755b39b63109e03 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:24:55 +0800 Subject: [PATCH 08/12] feat: add beforesave/aftersave hooks (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/store.ts | 3 +++ test/unit/store.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/store.ts b/src/store.ts index f334335..4fa57b9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -574,6 +574,8 @@ export const useStoryStore = create()( const { storyData, playthroughId } = get(); if (!storyData) return; + emit('beforesave', slot, custom); + const payload = get().getSavePayload(); set((state) => { @@ -587,6 +589,7 @@ export const useStoryStore = create()( [slot ?? '']: true, }; }); + emit('aftersave', slot); }) .catch((err) => { console.error('spindle: failed to save', err); diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 8e111af..6a9b59b 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -6,7 +6,7 @@ import { enterRuntimePhase, _resetRuntimePhase, } from '../../src/store'; -import { on as emitterOn } from '../../src/event-emitter'; +import { on as emitterOn, resetEmitter } from '../../src/event-emitter'; import { executeStoryInit } from '../../src/story-init'; import type { StoryData, Passage } from '../../src/parser'; import { registerClass, clearRegistry } from '../../src/class-registry'; @@ -966,4 +966,48 @@ describe('useStoryStore', () => { expect(unsub).toHaveBeenCalledOnce(); }); }); + + describe('save hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforesave before getSavePayload()', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + let capturedVars: Record | null = null; + emitterOn('beforesave', () => { + // Inject a variable — it should appear in the saved payload + useStoryStore.getState().setVariable('injected', 42); + capturedVars = { ...useStoryStore.getState().variables }; + }); + + useStoryStore.getState().save('test-slot'); + expect(capturedVars).toEqual({ injected: 42 }); + }); + + it('beforesave receives slot and custom args', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('beforesave', cb); + + useStoryStore.getState().save('slot-1', { meta: true }); + expect(cb).toHaveBeenCalledWith('slot-1', { meta: true }); + }); + + it('beforesave receives undefined for default slot', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('beforesave', cb); + + useStoryStore.getState().save(); + expect(cb).toHaveBeenCalledWith(undefined, undefined); + }); + }); }); From 8995fb0d896a2fd3f82f7ed63ba2589c6e8cdbb3 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:27:22 +0800 Subject: [PATCH 09/12] feat: add beforenavigate/afternavigate hooks (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/store.ts | 17 +++++ test/unit/store.test.ts | 142 ++++++++++++++++++++++++++++++++++++ test/unit/story-api.test.ts | 2 +- 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/store.ts b/src/store.ts index 4fa57b9..197ee6b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -391,6 +391,9 @@ export const useStoryStore = create()( return; } + const previousPassage = get().currentPassage; + emit('beforenavigate', passageName); + // Compute variable delta before Immer set() const patchEntry = computeVarPatches(lastNavigationVars, currVars); @@ -431,12 +434,18 @@ export const useStoryStore = create()( lastNavigationVars = get().variables; persistSession(get); + + emit('afternavigate', passageName, previousPassage); }, goBack: () => { const { historyIndex, variables } = get(); if (historyIndex <= 0) return; + const previousPassage = get().currentPassage; + const targetPassage = get().history[historyIndex - 1]!.passage; + emit('beforenavigate', targetPassage); + // Apply inverse transition: moment historyIndex → historyIndex−1 const restoredVars = deepClone( applyPatches(variables, patchEntries[historyIndex - 1]!.inverse), @@ -452,12 +461,18 @@ export const useStoryStore = create()( lastNavigationVars = get().variables; restorePRNGFromMoment(get().history[get().historyIndex]); persistSession(get); + + emit('afternavigate', targetPassage, previousPassage); }, goForward: () => { const { historyIndex, history: hist, variables } = get(); if (historyIndex >= hist.length - 1) return; + const previousPassage = get().currentPassage; + const targetPassage = hist[historyIndex + 1]!.passage; + emit('beforenavigate', targetPassage); + // Apply forward transition: moment historyIndex → historyIndex+1 const restoredVars = deepClone( applyPatches(variables, patchEntries[historyIndex]!.forward), @@ -473,6 +488,8 @@ export const useStoryStore = create()( lastNavigationVars = get().variables; restorePRNGFromMoment(get().history[get().historyIndex]); persistSession(get); + + emit('afternavigate', targetPassage, previousPassage); }, setVariable: (name: string, value: unknown) => { diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 6a9b59b..63d3329 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -1010,4 +1010,146 @@ describe('useStoryStore', () => { expect(cb).toHaveBeenCalledWith(undefined, undefined); }); }); + + describe('navigate hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforenavigate before state change', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let passageDuringHook: string | null = null; + emitterOn('beforenavigate', () => { + passageDuringHook = useStoryStore.getState().currentPassage; + }); + + useStoryStore.getState().navigate('Room'); + expect(passageDuringHook).toBe('Start'); + }); + + it('beforenavigate receives target passage name', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('beforenavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room'); + }); + + it('emits afternavigate after state change', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let passageDuringHook: string | null = null; + emitterOn('afternavigate', () => { + passageDuringHook = useStoryStore.getState().currentPassage; + }); + + useStoryStore.getState().navigate('Room'); + expect(passageDuringHook).toBe('Room'); + }); + + it('afternavigate receives (to, from)', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('afternavigate', cb); + + useStoryStore.getState().navigate('Room'); + expect(cb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('hooks fire on goBack()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + useStoryStore.getState().navigate('Room'); + + const beforeCb = vi.fn(); + const afterCb = vi.fn(); + emitterOn('beforenavigate', beforeCb); + emitterOn('afternavigate', afterCb); + + useStoryStore.getState().goBack(); + expect(beforeCb).toHaveBeenCalledWith('Start'); + expect(afterCb).toHaveBeenCalledWith('Start', 'Room'); + }); + + it('hooks fire on goForward()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + useStoryStore.getState().navigate('Room'); + useStoryStore.getState().goBack(); + + const beforeCb = vi.fn(); + const afterCb = vi.fn(); + emitterOn('beforenavigate', beforeCb); + emitterOn('afternavigate', afterCb); + + useStoryStore.getState().goForward(); + expect(beforeCb).toHaveBeenCalledWith('Room'); + expect(afterCb).toHaveBeenCalledWith('Room', 'Start'); + }); + + it('hooks do NOT fire on loadFromPayload()', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('beforenavigate', cb); + emitterOn('afternavigate', cb); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: {}, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: {}, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('no hooks fire for invalid passage', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + + const cb = vi.fn(); + emitterOn('beforenavigate', cb); + emitterOn('afternavigate', cb); + + useStoryStore.getState().navigate('Nonexistent'); + expect(cb).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index c706bf5..fea34d2 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -195,7 +195,7 @@ describe('StoryAPI', () => { }); describe('on(afternavigate)', () => { - it.skip('fires callback when passage changes via navigate', () => { + it('fires callback when passage changes via navigate', () => { // Enabled in Task 7 when navigate() emits afternavigate const cb = vi.fn(); const unsub = Story.on('afternavigate', cb); From 066c12d8eb13298a831b0b2e26b69a1355e08c3e Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:29:07 +0800 Subject: [PATCH 10/12] feat: add beforeload/afterload hooks (#129) Co-Authored-By: Claude Sonnet 4.6 --- src/store.ts | 5 ++++ test/unit/store.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/store.ts b/src/store.ts index 197ee6b..43bee61 100644 --- a/src/store.ts +++ b/src/store.ts @@ -762,6 +762,9 @@ export const useStoryStore = create()( console.warn('loadFromPayload: rejecting payload with empty history'); return; } + + emit('beforeload', undefined); + // Convert full snapshots to patch entries const base = deserialize(payload.history[0]?.variables ?? {}) as Record< string, @@ -810,6 +813,8 @@ export const useStoryStore = create()( } else { resetPRNG(); } + + emit('afterload', undefined); }, getHistoryVariables: (index: number): Record => { diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 63d3329..a4d4bab 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -1152,4 +1152,64 @@ describe('useStoryStore', () => { expect(cb).not.toHaveBeenCalled(); }); }); + + describe('load hooks', () => { + beforeEach(() => { + resetEmitter(); + _resetRuntimePhase(); + }); + + it('emits beforeload/afterload around loadFromPayload', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + const order: string[] = []; + emitterOn('beforeload', () => order.push('beforeload')); + emitterOn('afterload', () => order.push('afterload')); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: { gold: 100 }, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: { gold: 100 }, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(order).toEqual(['beforeload', 'afterload']); + }); + + it('afterload fires after state is restored', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + let restoredGold: unknown = null; + emitterOn('afterload', () => { + restoredGold = useStoryStore.getState().variables.gold; + }); + + useStoryStore.getState().loadFromPayload({ + passage: 'Room', + variables: { gold: 100 }, + history: [ + { passage: 'Start', variables: {}, timestamp: 1 }, + { passage: 'Room', variables: { gold: 100 }, timestamp: 2 }, + ], + historyIndex: 1, + visitCounts: { Start: 1, Room: 1 }, + renderCounts: { Start: 1, Room: 1 }, + }); + + expect(restoredGold).toBe(100); + }); + }); }); From f928c4cb3b91b9ee9b33483b5236072c65cfee80 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:30:41 +0800 Subject: [PATCH 11/12] test: add auto-cleanup integration tests for new hooks (#129) Co-Authored-By: Claude Sonnet 4.6 --- test/unit/store.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index a4d4bab..cf74c54 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -891,6 +891,7 @@ describe('useStoryStore', () => { describe('runtime handler cleanup', () => { beforeEach(() => { + resetEmitter(); _resetRuntimePhase(); useStoryStore.setState({ storyData: null, @@ -965,6 +966,50 @@ describe('useStoryStore', () => { useStoryStore.getState().restart(); expect(unsub).toHaveBeenCalledOnce(); }); + + it('runtime-registered hook unsubs are called on restart', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + enterRuntimePhase(); + + const cb = vi.fn(); + const unsub = emitterOn('beforesave', cb); + trackRuntimeUnsub(unsub); + + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); + + useStoryStore.getState().restart(); + + cb.mockClear(); + useStoryStore.getState().save('test'); + expect(cb).not.toHaveBeenCalled(); + }); + + it('startup-registered hooks survive restart', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + + // Register BEFORE entering runtime phase + const cb = vi.fn(); + emitterOn('beforesave', cb); + + enterRuntimePhase(); + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); + + useStoryStore.getState().restart(); + + cb.mockClear(); + useStoryStore.getState().save('test'); + expect(cb).toHaveBeenCalledTimes(1); + }); }); describe('save hooks', () => { From 5a64ef64811699d14565cfc1759ef5cf0e9ba906 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 00:37:29 +0800 Subject: [PATCH 12/12] fix: restore type-safe Story.on() signature using StoryEvent/StoryEventCallback (#129) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/story-api.ts | 19 +++++++++++++++---- test/unit/story-api.test.ts | 1 - 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/story-api.ts b/src/story-api.ts index c863d9e..4e3e06c 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -1,5 +1,10 @@ import { useStoryStore, trackRuntimeUnsub } from './store'; -import { on as emitterOn, emit } from './event-emitter'; +import { + on as emitterOn, + emit, + type StoryEvent, + type StoryEventCallback, +} from './event-emitter'; import type { Passage } from './parser'; import { settings } from './settings'; import type { @@ -134,7 +139,10 @@ export interface StoryAPI { }; getActions(): StoryAction[]; performAction(id: string, value?: unknown): void; - on(event: string, callback: (...args: any[]) => void): () => void; + on( + event: E, + callback: StoryEventCallback, + ): () => void; waitForActions(): Promise; watch( condition: string, @@ -379,11 +387,14 @@ function createStoryAPI(): StoryAPI { action.perform(value); }, - on(event: string, callback: (...args: any[]) => void): () => void { + on( + event: E, + callback: StoryEventCallback, + ): () => void { if (event === 'variableChanged') { ensureVariableChangedSubscription(); } - const unsub = emitterOn(event as any, callback as any); + const unsub = emitterOn(event, callback); trackRuntimeUnsub(unsub); return unsub; }, diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index fea34d2..3710d6f 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -196,7 +196,6 @@ describe('StoryAPI', () => { describe('on(afternavigate)', () => { it('fires callback when passage changes via navigate', () => { - // Enabled in Task 7 when navigate() emits afternavigate const cb = vi.fn(); const unsub = Story.on('afternavigate', cb);