@rohal12/spindle 0.40.1 → 0.41.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/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/action-registry.ts +3 -11
- package/src/event-emitter.ts +71 -0
- package/src/index.tsx +3 -2
- package/src/store.ts +28 -41
- package/src/story-api.ts +45 -78
package/package.json
CHANGED
package/src/action-registry.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from './
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
+
const unsub = emitterOn(event, callback);
|
|
398
|
+
trackRuntimeUnsub(unsub);
|
|
399
|
+
return unsub;
|
|
433
400
|
},
|
|
434
401
|
|
|
435
402
|
waitForActions(): Promise<StoryAction[]> {
|