@rohal12/spindle 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.40.1",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,3 +1,5 @@
1
+ import { emit } from './event-emitter';
2
+
1
3
  export type ActionType =
2
4
  | 'link'
3
5
  | 'button'
@@ -28,7 +30,6 @@ export interface StoryAction {
28
30
  }
29
31
 
30
32
  const actions = new Map<string, StoryAction>();
31
- const listeners = new Set<() => void>();
32
33
  const idCounters = new Map<string, number>();
33
34
 
34
35
  export function generateActionId(
@@ -79,15 +80,6 @@ export function resetIdCounters(): void {
79
80
  idCounters.clear();
80
81
  }
81
82
 
82
- export function onActionsChanged(fn: () => void): () => void {
83
- listeners.add(fn);
84
- return () => {
85
- listeners.delete(fn);
86
- };
87
- }
88
-
89
83
  function notify(): void {
90
- for (const fn of listeners) {
91
- fn();
92
- }
84
+ emit('actionsChanged');
93
85
  }
@@ -0,0 +1,71 @@
1
+ type EventMap = {
2
+ storyinit: () => void;
3
+ beforerestart: () => void;
4
+ actionsChanged: () => void;
5
+ variableChanged: (
6
+ changed: Record<string, { from: unknown; to: unknown }>,
7
+ ) => void;
8
+ beforesave: (
9
+ slot: string | undefined,
10
+ custom: Record<string, unknown> | undefined,
11
+ ) => void;
12
+ aftersave: (slot: string | undefined) => void;
13
+ beforeload: (slot: string | undefined) => void;
14
+ afterload: (slot: string | undefined) => void;
15
+ beforenavigate: (passageName: string) => void;
16
+ afternavigate: (to: string, from: string) => void;
17
+ };
18
+
19
+ export type StoryEvent = keyof EventMap;
20
+ export type StoryEventCallback<E extends StoryEvent> = EventMap[E];
21
+
22
+ const VALID_EVENTS = new Set<string>([
23
+ 'storyinit',
24
+ 'beforerestart',
25
+ 'actionsChanged',
26
+ 'variableChanged',
27
+ 'beforesave',
28
+ 'aftersave',
29
+ 'beforeload',
30
+ 'afterload',
31
+ 'beforenavigate',
32
+ 'afternavigate',
33
+ ]);
34
+
35
+ // Each event key maps to a Set of callbacks.
36
+ let listeners = new Map<string, Set<Function>>();
37
+
38
+ export function on<E extends StoryEvent>(
39
+ event: E,
40
+ cb: EventMap[E],
41
+ ): () => void {
42
+ if (!VALID_EVENTS.has(event)) {
43
+ throw new Error(`spindle: Unknown event "${event}".`);
44
+ }
45
+ let set = listeners.get(event);
46
+ if (!set) {
47
+ set = new Set();
48
+ listeners.set(event, set);
49
+ }
50
+ set.add(cb);
51
+ return () => {
52
+ set!.delete(cb);
53
+ };
54
+ }
55
+
56
+ export function emit<E extends StoryEvent>(
57
+ event: E,
58
+ ...args: Parameters<EventMap[E]>
59
+ ): void {
60
+ const set = listeners.get(event);
61
+ if (!set) return;
62
+ // Snapshot to tolerate unsubscription during iteration
63
+ for (const cb of [...set]) {
64
+ (cb as Function)(...args);
65
+ }
66
+ }
67
+
68
+ /** Test-only: clear all listeners. */
69
+ export function resetEmitter(): void {
70
+ listeners = new Map();
71
+ }
package/src/index.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
- import { useStoryStore, fireStoryInit, enterRuntimePhase } from './store';
4
+ import { useStoryStore, enterRuntimePhase } from './store';
5
+ import { emit } from './event-emitter';
5
6
  import { installStoryAPI, getReadyPromise } from './story-api';
6
7
  import { resetIdCounters } from './action-registry';
7
8
  import { executeStoryInit } from './story-init';
@@ -112,7 +113,7 @@ function boot() {
112
113
  }
113
114
 
114
115
  // Fire storyinit after all state is settled (defaults + StoryInit + session)
115
- fireStoryInit();
116
+ emit('storyinit');
116
117
 
117
118
  // Pass 1: Pre-scan all widget passages to discover block widgets.
118
119
  // Register them as block macros BEFORE any tokenize/buildAST calls,
package/src/store.ts CHANGED
@@ -10,6 +10,7 @@ import type { StoryData } from './parser';
10
10
  import type { TransitionConfig } from './transition';
11
11
  import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types';
12
12
  import { executeStoryInit } from './story-init';
13
+ import { emit } from './event-emitter';
13
14
  import { resetTriggers } from './triggers';
14
15
  import {
15
16
  initSaveSystem,
@@ -212,45 +213,6 @@ export function _resetRuntimePhase(): void {
212
213
  inRuntimePhase = false;
213
214
  }
214
215
 
215
- // ---------------------------------------------------------------------------
216
- // storyinit callbacks (direct invocation — avoids Zustand subscription issues)
217
- // ---------------------------------------------------------------------------
218
-
219
- type StoryInitListener = () => void;
220
- let storyInitListeners: StoryInitListener[] = [];
221
-
222
- /** Register a callback to run after StoryInit completes (boot + every restart). */
223
- export function onStoryInit(cb: StoryInitListener): () => void {
224
- storyInitListeners.push(cb);
225
- return () => {
226
- storyInitListeners = storyInitListeners.filter((l) => l !== cb);
227
- };
228
- }
229
-
230
- /** Fire all storyinit listeners. Called after all state resets are complete. */
231
- export function fireStoryInit(): void {
232
- for (const cb of storyInitListeners) cb();
233
- }
234
-
235
- // ---------------------------------------------------------------------------
236
- // beforerestart callbacks
237
- // ---------------------------------------------------------------------------
238
-
239
- type BeforeRestartListener = () => void;
240
- let beforeRestartListeners: BeforeRestartListener[] = [];
241
-
242
- /** Register a callback to run before restart resets any state. */
243
- export function onBeforeRestart(cb: BeforeRestartListener): () => void {
244
- beforeRestartListeners.push(cb);
245
- return () => {
246
- beforeRestartListeners = beforeRestartListeners.filter((l) => l !== cb);
247
- };
248
- }
249
-
250
- function fireBeforeRestart(): void {
251
- for (const cb of beforeRestartListeners) cb();
252
- }
253
-
254
216
  // ---------------------------------------------------------------------------
255
217
  // Store
256
218
  // ---------------------------------------------------------------------------
@@ -429,6 +391,9 @@ export const useStoryStore = create<StoryState>()(
429
391
  return;
430
392
  }
431
393
 
394
+ const previousPassage = get().currentPassage;
395
+ emit('beforenavigate', passageName);
396
+
432
397
  // Compute variable delta before Immer set()
433
398
  const patchEntry = computeVarPatches(lastNavigationVars, currVars);
434
399
 
@@ -469,12 +434,18 @@ export const useStoryStore = create<StoryState>()(
469
434
 
470
435
  lastNavigationVars = get().variables;
471
436
  persistSession(get);
437
+
438
+ emit('afternavigate', passageName, previousPassage);
472
439
  },
473
440
 
474
441
  goBack: () => {
475
442
  const { historyIndex, variables } = get();
476
443
  if (historyIndex <= 0) return;
477
444
 
445
+ const previousPassage = get().currentPassage;
446
+ const targetPassage = get().history[historyIndex - 1]!.passage;
447
+ emit('beforenavigate', targetPassage);
448
+
478
449
  // Apply inverse transition: moment historyIndex → historyIndex−1
479
450
  const restoredVars = deepClone(
480
451
  applyPatches(variables, patchEntries[historyIndex - 1]!.inverse),
@@ -490,12 +461,18 @@ export const useStoryStore = create<StoryState>()(
490
461
  lastNavigationVars = get().variables;
491
462
  restorePRNGFromMoment(get().history[get().historyIndex]);
492
463
  persistSession(get);
464
+
465
+ emit('afternavigate', targetPassage, previousPassage);
493
466
  },
494
467
 
495
468
  goForward: () => {
496
469
  const { historyIndex, history: hist, variables } = get();
497
470
  if (historyIndex >= hist.length - 1) return;
498
471
 
472
+ const previousPassage = get().currentPassage;
473
+ const targetPassage = hist[historyIndex + 1]!.passage;
474
+ emit('beforenavigate', targetPassage);
475
+
499
476
  // Apply forward transition: moment historyIndex → historyIndex+1
500
477
  const restoredVars = deepClone(
501
478
  applyPatches(variables, patchEntries[historyIndex]!.forward),
@@ -511,6 +488,8 @@ export const useStoryStore = create<StoryState>()(
511
488
  lastNavigationVars = get().variables;
512
489
  restorePRNGFromMoment(get().history[get().historyIndex]);
513
490
  persistSession(get);
491
+
492
+ emit('afternavigate', targetPassage, previousPassage);
514
493
  },
515
494
 
516
495
  setVariable: (name: string, value: unknown) => {
@@ -557,7 +536,7 @@ export const useStoryStore = create<StoryState>()(
557
536
  state.renderDeferred = false;
558
537
  });
559
538
 
560
- fireBeforeRestart();
539
+ emit('beforerestart');
561
540
 
562
541
  const keepDeferred = get().renderDeferred;
563
542
 
@@ -594,7 +573,7 @@ export const useStoryStore = create<StoryState>()(
594
573
 
595
574
  executeStoryInit();
596
575
  clearSession(storyData.ifid);
597
- fireStoryInit();
576
+ emit('storyinit');
598
577
 
599
578
  // Start a new playthrough on restart
600
579
  startNewPlaythrough(storyData.ifid)
@@ -612,6 +591,8 @@ export const useStoryStore = create<StoryState>()(
612
591
  const { storyData, playthroughId } = get();
613
592
  if (!storyData) return;
614
593
 
594
+ emit('beforesave', slot, custom);
595
+
615
596
  const payload = get().getSavePayload();
616
597
 
617
598
  set((state) => {
@@ -625,6 +606,7 @@ export const useStoryStore = create<StoryState>()(
625
606
  [slot ?? '']: true,
626
607
  };
627
608
  });
609
+ emit('aftersave', slot);
628
610
  })
629
611
  .catch((err) => {
630
612
  console.error('spindle: failed to save', err);
@@ -780,6 +762,9 @@ export const useStoryStore = create<StoryState>()(
780
762
  console.warn('loadFromPayload: rejecting payload with empty history');
781
763
  return;
782
764
  }
765
+
766
+ emit('beforeload', undefined);
767
+
783
768
  // Convert full snapshots to patch entries
784
769
  const base = deserialize(payload.history[0]?.variables ?? {}) as Record<
785
770
  string,
@@ -828,6 +813,8 @@ export const useStoryStore = create<StoryState>()(
828
813
  } else {
829
814
  resetPRNG();
830
815
  }
816
+
817
+ emit('afterload', undefined);
831
818
  },
832
819
 
833
820
  getHistoryVariables: (index: number): Record<string, unknown> => {
package/src/story-api.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import { useStoryStore, trackRuntimeUnsub } from './store';
1
2
  import {
2
- useStoryStore,
3
- onStoryInit,
4
- onBeforeRestart,
5
- trackRuntimeUnsub,
6
- } from './store';
3
+ on as emitterOn,
4
+ emit,
5
+ type StoryEvent,
6
+ type StoryEventCallback,
7
+ } from './event-emitter';
7
8
  import type { Passage } from './parser';
8
9
  import { settings } from './settings';
9
10
  import type {
@@ -26,12 +27,7 @@ import { defineMacro } from './define-macro';
26
27
  import type { MacroDefinition } from './define-macro';
27
28
  import { getMacroRegistry as _getMacroRegistry } from './registry';
28
29
  import type { MacroMetadata } from './registry';
29
- import {
30
- getActions,
31
- getAction,
32
- onActionsChanged,
33
- type StoryAction,
34
- } from './action-registry';
30
+ import { getActions, getAction, type StoryAction } from './action-registry';
35
31
  import {
36
32
  initPRNG,
37
33
  isPRNGEnabled,
@@ -73,13 +69,32 @@ export function _resetReadyState(): void {
73
69
  readyPromise = null;
74
70
  }
75
71
 
76
- type NavigateCallback = (to: string, from: string) => void;
77
- type StoryInitCallback = () => void;
78
- type BeforeRestartCallback = () => void;
79
- type ActionsChangedCallback = () => void;
80
- type VariableChangedCallback = (
81
- changed: Record<string, { from: unknown; to: unknown }>,
82
- ) => void;
72
+ /** Lazily created shared Zustand subscription for variableChanged. */
73
+ let variableChangedSubActive = false;
74
+
75
+ function ensureVariableChangedSubscription(): void {
76
+ if (variableChangedSubActive) return;
77
+ variableChangedSubActive = true;
78
+ let prevVars = { ...useStoryStore.getState().variables };
79
+ useStoryStore.subscribe((state) => {
80
+ const changed: Record<string, { from: unknown; to: unknown }> = {};
81
+ let hasChanges = false;
82
+ const allKeys = new Set([
83
+ ...Object.keys(prevVars),
84
+ ...Object.keys(state.variables),
85
+ ]);
86
+ for (const key of allKeys) {
87
+ if (state.variables[key] !== prevVars[key]) {
88
+ changed[key] = { from: prevVars[key], to: state.variables[key] };
89
+ hasChanges = true;
90
+ }
91
+ }
92
+ prevVars = { ...state.variables };
93
+ if (hasChanges) {
94
+ emit('variableChanged', changed);
95
+ }
96
+ });
97
+ }
83
98
 
84
99
  export interface StoryAPI {
85
100
  get(name: string): unknown;
@@ -124,11 +139,10 @@ export interface StoryAPI {
124
139
  };
125
140
  getActions(): StoryAction[];
126
141
  performAction(id: string, value?: unknown): void;
127
- on(event: 'navigate', callback: NavigateCallback): () => void;
128
- on(event: 'beforerestart', callback: BeforeRestartCallback): () => void;
129
- on(event: 'storyinit', callback: StoryInitCallback): () => void;
130
- on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void;
131
- on(event: 'variableChanged', callback: VariableChangedCallback): () => void;
142
+ on<E extends StoryEvent>(
143
+ event: E,
144
+ callback: StoryEventCallback<E>,
145
+ ): () => void;
132
146
  waitForActions(): Promise<StoryAction[]>;
133
147
  watch(
134
148
  condition: string,
@@ -373,63 +387,16 @@ function createStoryAPI(): StoryAPI {
373
387
  action.perform(value);
374
388
  },
375
389
 
376
- on(event: string, callback: (...args: any[]) => void): () => void {
377
- if (event === 'navigate') {
378
- let prev = useStoryStore.getState().currentPassage;
379
- const unsub = useStoryStore.subscribe((state) => {
380
- if (state.currentPassage !== prev) {
381
- const from = prev;
382
- prev = state.currentPassage;
383
- (callback as NavigateCallback)(state.currentPassage, from);
384
- }
385
- });
386
- trackRuntimeUnsub(unsub);
387
- return unsub;
388
- }
389
-
390
- if (event === 'beforerestart') {
391
- const unsub = onBeforeRestart(callback as BeforeRestartCallback);
392
- trackRuntimeUnsub(unsub);
393
- return unsub;
394
- }
395
-
396
- if (event === 'storyinit') {
397
- const unsub = onStoryInit(callback as StoryInitCallback);
398
- trackRuntimeUnsub(unsub);
399
- return unsub;
400
- }
401
-
402
- if (event === 'actionsChanged') {
403
- const unsub = onActionsChanged(callback as ActionsChangedCallback);
404
- trackRuntimeUnsub(unsub);
405
- return unsub;
406
- }
407
-
390
+ on<E extends StoryEvent>(
391
+ event: E,
392
+ callback: StoryEventCallback<E>,
393
+ ): () => void {
408
394
  if (event === 'variableChanged') {
409
- let prevVars = { ...useStoryStore.getState().variables };
410
- const unsub = useStoryStore.subscribe((state) => {
411
- const changed: Record<string, { from: unknown; to: unknown }> = {};
412
- let hasChanges = false;
413
- const allKeys = new Set([
414
- ...Object.keys(prevVars),
415
- ...Object.keys(state.variables),
416
- ]);
417
- for (const key of allKeys) {
418
- if (state.variables[key] !== prevVars[key]) {
419
- changed[key] = { from: prevVars[key], to: state.variables[key] };
420
- hasChanges = true;
421
- }
422
- }
423
- prevVars = { ...state.variables };
424
- if (hasChanges) {
425
- (callback as VariableChangedCallback)(changed);
426
- }
427
- });
428
- trackRuntimeUnsub(unsub);
429
- return unsub;
395
+ ensureVariableChangedSubscription();
430
396
  }
431
-
432
- throw new Error(`spindle: Unknown event "${event}".`);
397
+ const unsub = emitterOn(event, callback);
398
+ trackRuntimeUnsub(unsub);
399
+ return unsub;
433
400
  },
434
401
 
435
402
  waitForActions(): Promise<StoryAction[]> {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compile-time check: the hand-written types/index.d.ts must stay in sync
3
+ * with the source StoryAPI interface. If this file fails to compile,
4
+ * the published types have drifted from the implementation.
5
+ *
6
+ * Run: npx tsc --noEmit
7
+ */
8
+ import type { StoryAPI as SourceAPI } from './story-api';
9
+ import type { StoryAPI as PublishedAPI } from '../types/index';
10
+
11
+ // Both directions — if either fails, the types have drifted.
12
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
+ const _sourceToPublished: PublishedAPI = {} as SourceAPI;
14
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
+ const _publishedToSource: SourceAPI = {} as PublishedAPI;