@rohal12/spindle 0.49.1 → 0.50.1
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/dist/pkg/headless.js +7396 -0
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +6 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +23 -3
- package/src/components/macros/Button.tsx +2 -1
- package/src/components/macros/Do.tsx +1 -0
- package/src/components/macros/For.tsx +4 -0
- package/src/components/macros/Include.tsx +13 -6
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/PassageDisplay.tsx +17 -10
- package/src/components/macros/SaveManager.tsx +18 -5
- package/src/components/macros/Watch.tsx +3 -2
- package/src/components/macros/WidgetInvocation.tsx +4 -0
- package/src/execute-mutation.ts +83 -31
- package/src/headless.ts +87 -0
- package/src/index.tsx +21 -26
- package/src/interpolation.ts +4 -9
- package/src/main.tsx +8 -0
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/passage-render-state.ts +14 -0
- package/src/saves/save-manager.ts +97 -39
- package/src/settings.ts +15 -0
- package/src/store.ts +209 -56
- package/src/story-api.ts +40 -25
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/types-drift-check.ts +12 -0
- package/src/utils/live-locals.ts +22 -0
- package/types/headless.d.ts +27 -0
package/src/settings.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type SettingDef =
|
|
|
27
27
|
const definitions = new Map<string, SettingDef>();
|
|
28
28
|
let values: Record<string, unknown> = {};
|
|
29
29
|
let storageLoaded = false;
|
|
30
|
+
let unsubscribeStoryData: (() => void) | null = null;
|
|
30
31
|
|
|
31
32
|
function storageKey(): string {
|
|
32
33
|
const storyData = useStoryStore.getState().storyData;
|
|
@@ -40,6 +41,20 @@ function persist(): void {
|
|
|
40
41
|
|
|
41
42
|
function loadFromStorage(): void {
|
|
42
43
|
if (storageLoaded) return;
|
|
44
|
+
// Settings registered from Story JavaScript run before store.init(), when
|
|
45
|
+
// the IFID (and thus the storage key) is not known yet. Defer the load
|
|
46
|
+
// until story data arrives instead of reading `spindle.unknown.settings`.
|
|
47
|
+
if (!useStoryStore.getState().storyData?.ifid) {
|
|
48
|
+
if (!unsubscribeStoryData) {
|
|
49
|
+
unsubscribeStoryData = useStoryStore.subscribe((state) => {
|
|
50
|
+
if (!state.storyData?.ifid) return;
|
|
51
|
+
unsubscribeStoryData?.();
|
|
52
|
+
unsubscribeStoryData = null;
|
|
53
|
+
loadFromStorage();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
43
58
|
storageLoaded = true;
|
|
44
59
|
try {
|
|
45
60
|
const raw = localStorage.getItem(storageKey());
|
package/src/store.ts
CHANGED
|
@@ -17,12 +17,17 @@ import type {
|
|
|
17
17
|
import { isSaveExport } from './saves/types';
|
|
18
18
|
import { executeStoryInit } from './story-init';
|
|
19
19
|
import { emit } from './event-emitter';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
resetTriggers,
|
|
22
|
+
checkTriggersOnNavigation,
|
|
23
|
+
reinitTriggerState,
|
|
24
|
+
} from './triggers';
|
|
21
25
|
import {
|
|
22
26
|
initSaveSystem,
|
|
23
27
|
startNewPlaythrough,
|
|
24
28
|
getCurrentPlaythroughId,
|
|
25
29
|
quickSave,
|
|
30
|
+
saveWithHooks,
|
|
26
31
|
loadQuickSave,
|
|
27
32
|
populateKnownSaves,
|
|
28
33
|
getSlotSaveInfo,
|
|
@@ -36,7 +41,7 @@ import {
|
|
|
36
41
|
clearAllData as smClearAllData,
|
|
37
42
|
deletePlaythroughData as smDeletePlaythroughData,
|
|
38
43
|
} from './saves/save-manager';
|
|
39
|
-
import { deepClone, serialize
|
|
44
|
+
import { deepClone, serialize } from './class-registry';
|
|
40
45
|
import {
|
|
41
46
|
snapshotPRNG,
|
|
42
47
|
restorePRNG,
|
|
@@ -106,6 +111,19 @@ function computeVarPatches(
|
|
|
106
111
|
return { forward: clonePatches(forward), inverse: clonePatches(inverse) };
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Replace the variable snapshot recorded for the newest history moment
|
|
116
|
+
* (e.g. with changes made by watchers reacting to the navigation into it).
|
|
117
|
+
*/
|
|
118
|
+
function rerecordNewestMoment(vars: Record<string, unknown>): void {
|
|
119
|
+
const last = patchEntries.length - 1;
|
|
120
|
+
if (last < 0) {
|
|
121
|
+
variableBase = vars;
|
|
122
|
+
} else {
|
|
123
|
+
patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
/** Reconstruct variables at a given history moment by replaying patches. */
|
|
110
128
|
function reconstructVarsAt(index: number): Record<string, unknown> {
|
|
111
129
|
let vars: Record<string, unknown> = variableBase;
|
|
@@ -133,7 +151,8 @@ function persistSession(get: () => StoryState): void {
|
|
|
133
151
|
} = get();
|
|
134
152
|
if (!storyData) return;
|
|
135
153
|
|
|
136
|
-
// Trim cache when history
|
|
154
|
+
// Trim cache when history shrank (navigate() drops discarded forward
|
|
155
|
+
// moments itself, since a replacement branch may keep the same length)
|
|
137
156
|
if (serializedHistory.length > history.length) {
|
|
138
157
|
serializedHistory.length = history.length;
|
|
139
158
|
}
|
|
@@ -156,10 +175,9 @@ function persistSession(get: () => StoryState): void {
|
|
|
156
175
|
for (let i = 0; i < history.length; i++) {
|
|
157
176
|
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
158
177
|
if (i >= serializedHistory.length) {
|
|
159
|
-
const v = i === history.length - 1 ? variables : vars;
|
|
160
178
|
serializedHistory[i] = {
|
|
161
179
|
passage: history[i]!.passage,
|
|
162
|
-
variables: serialize(
|
|
180
|
+
variables: serialize(vars),
|
|
163
181
|
timestamp: history[i]!.timestamp,
|
|
164
182
|
prng: history[i]!.prng,
|
|
165
183
|
};
|
|
@@ -179,6 +197,12 @@ function persistSession(get: () => StoryState): void {
|
|
|
179
197
|
});
|
|
180
198
|
}
|
|
181
199
|
|
|
200
|
+
/** True while navigate() lets watchers react to the moment it entered. */
|
|
201
|
+
let navigationTriggerPhase = false;
|
|
202
|
+
|
|
203
|
+
/** Navigations requested during the trigger phase, run once it is over. */
|
|
204
|
+
let deferredNavigations: string[] = [];
|
|
205
|
+
|
|
182
206
|
/** Reset all module-level state (called on init, restart, loadFromPayload). */
|
|
183
207
|
function resetModuleState(base: Record<string, unknown>): void {
|
|
184
208
|
variableBase = base;
|
|
@@ -187,6 +211,27 @@ function resetModuleState(base: Record<string, unknown>): void {
|
|
|
187
211
|
serializedHistory = [];
|
|
188
212
|
}
|
|
189
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Record the state StoryInit left behind as the start moment: its variable
|
|
216
|
+
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
217
|
+
* after the StoryInit passage has rendered; init()/restart() record the
|
|
218
|
+
* start moment before StoryInit runs. A no-op once the story has moved on.
|
|
219
|
+
*/
|
|
220
|
+
export function recordStoryInitState(): void {
|
|
221
|
+
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
222
|
+
if (history.length !== 1 || historyIndex !== 0) return;
|
|
223
|
+
|
|
224
|
+
variableBase = variables;
|
|
225
|
+
patchEntries = [];
|
|
226
|
+
lastNavigationVars = variables;
|
|
227
|
+
serializedHistory = [];
|
|
228
|
+
|
|
229
|
+
const prng = snapshotPRNG();
|
|
230
|
+
useStoryStore.setState((state) => {
|
|
231
|
+
state.history[0]!.prng = prng;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
190
235
|
// ---------------------------------------------------------------------------
|
|
191
236
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
192
237
|
// ---------------------------------------------------------------------------
|
|
@@ -231,6 +276,25 @@ function normalizeHotkey(key: unknown): string | null {
|
|
|
231
276
|
return typeof key === 'string' && key !== '' ? key : null;
|
|
232
277
|
}
|
|
233
278
|
|
|
279
|
+
/**
|
|
280
|
+
* The history moment whose entry snapshot a load restores: the moment at
|
|
281
|
+
* `historyIndex`, provided it is in range and belongs to the saved passage.
|
|
282
|
+
* Otherwise (malformed or foreign payloads) the load falls back to the
|
|
283
|
+
* payload's live variables.
|
|
284
|
+
*/
|
|
285
|
+
function loadedEntryMoment(
|
|
286
|
+
payload: SavePayload,
|
|
287
|
+
): SaveHistoryMoment | undefined {
|
|
288
|
+
const { history, historyIndex } = payload;
|
|
289
|
+
if (!Number.isInteger(historyIndex)) return undefined;
|
|
290
|
+
const moment = history[historyIndex];
|
|
291
|
+
if (!moment || moment.passage !== payload.passage) return undefined;
|
|
292
|
+
if (typeof moment.variables !== 'object' || moment.variables === null) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return moment;
|
|
296
|
+
}
|
|
297
|
+
|
|
234
298
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
235
299
|
function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
236
300
|
if (moment?.prng) {
|
|
@@ -246,9 +310,21 @@ export interface HistoryMoment {
|
|
|
246
310
|
prng?: PRNGSnapshot | null;
|
|
247
311
|
}
|
|
248
312
|
|
|
313
|
+
/** The variable namespaces watch conditions and mutations can touch. */
|
|
314
|
+
export interface VariableNamespaces {
|
|
315
|
+
variables: Record<string, unknown>;
|
|
316
|
+
temporary: Record<string, unknown>;
|
|
317
|
+
transient: Record<string, unknown>;
|
|
318
|
+
}
|
|
319
|
+
|
|
249
320
|
export interface StoryState {
|
|
250
321
|
storyData: StoryData | null;
|
|
251
322
|
currentPassage: string;
|
|
323
|
+
/**
|
|
324
|
+
* Incremented on every navigation (navigate, back, forward, restart, load)
|
|
325
|
+
* so the passage display can remount even when the passage name is unchanged.
|
|
326
|
+
*/
|
|
327
|
+
navigationId: number;
|
|
252
328
|
variables: Record<string, unknown>;
|
|
253
329
|
variableDefaults: Record<string, unknown>;
|
|
254
330
|
transient: Record<string, unknown>;
|
|
@@ -287,6 +363,11 @@ export interface StoryState {
|
|
|
287
363
|
deleteTemporary: (name: string) => void;
|
|
288
364
|
setTransient: (name: string, value: unknown) => void;
|
|
289
365
|
deleteTransient: (name: string) => void;
|
|
366
|
+
/**
|
|
367
|
+
* Apply changes to several variables as one store update, so subscribers
|
|
368
|
+
* (watchers, components) see them together instead of half-applied.
|
|
369
|
+
*/
|
|
370
|
+
updateVariables: (recipe: (draft: VariableNamespaces) => void) => void;
|
|
290
371
|
trackRender: (passageName: string) => void;
|
|
291
372
|
restart: () => void;
|
|
292
373
|
save: (slot?: string, custom?: Record<string, unknown>) => void;
|
|
@@ -301,7 +382,11 @@ export interface StoryState {
|
|
|
301
382
|
clearAllData: () => void;
|
|
302
383
|
deletePlaythrough: (playthroughId: string) => void;
|
|
303
384
|
getSavePayload: () => SavePayload;
|
|
304
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Replace the game state with a live (deserialized) payload. `slot` is
|
|
387
|
+
* passed to the `beforeload`/`afterload` events.
|
|
388
|
+
*/
|
|
389
|
+
loadFromPayload: (payload: SavePayload, slot?: string) => void;
|
|
305
390
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
306
391
|
setTransition: (config: TransitionConfig | null) => void;
|
|
307
392
|
setNextTransition: (config: TransitionConfig | null) => void;
|
|
@@ -314,6 +399,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
314
399
|
immer((set, get) => ({
|
|
315
400
|
storyData: null,
|
|
316
401
|
currentPassage: '',
|
|
402
|
+
navigationId: 0,
|
|
317
403
|
variables: {},
|
|
318
404
|
variableDefaults: {},
|
|
319
405
|
transient: {},
|
|
@@ -371,6 +457,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
371
457
|
set((state) => {
|
|
372
458
|
state.storyData = storyData as StoryData;
|
|
373
459
|
state.currentPassage = startPassage.name;
|
|
460
|
+
state.navigationId++;
|
|
374
461
|
state.variables = initialVars;
|
|
375
462
|
state.variableDefaults = variableDefaults;
|
|
376
463
|
state.transient = deepClone(transientDefaults);
|
|
@@ -420,7 +507,14 @@ export const useStoryStore = create<StoryState>()(
|
|
|
420
507
|
},
|
|
421
508
|
|
|
422
509
|
navigate: (passageName: string) => {
|
|
423
|
-
|
|
510
|
+
// A watcher (goto action or callback) reacting to a navigation must not
|
|
511
|
+
// start another one before the first has recorded its moment.
|
|
512
|
+
if (navigationTriggerPhase) {
|
|
513
|
+
deferredNavigations.push(passageName);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const { storyData } = get();
|
|
424
518
|
if (!storyData) return;
|
|
425
519
|
|
|
426
520
|
if (SPECIAL_PASSAGES.has(passageName)) {
|
|
@@ -438,16 +532,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
438
532
|
const previousPassage = get().currentPassage;
|
|
439
533
|
emit('beforenavigate', passageName);
|
|
440
534
|
|
|
441
|
-
// Compute variable delta before Immer set()
|
|
442
|
-
|
|
535
|
+
// Compute variable delta before Immer set(). Read the variables after
|
|
536
|
+
// beforenavigate so changes made by its handlers are recorded.
|
|
537
|
+
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
443
538
|
|
|
444
539
|
set((state) => {
|
|
445
540
|
state.temporary = {};
|
|
446
541
|
state.currentPassage = passageName;
|
|
542
|
+
state.navigationId++;
|
|
447
543
|
|
|
448
544
|
// Truncate forward history if we navigated back then chose a new path
|
|
449
545
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
450
546
|
patchEntries.length = state.historyIndex;
|
|
547
|
+
if (serializedHistory.length > state.historyIndex + 1) {
|
|
548
|
+
serializedHistory.length = state.historyIndex + 1;
|
|
549
|
+
}
|
|
451
550
|
|
|
452
551
|
// Push new transition and moment
|
|
453
552
|
patchEntries.push(patchEntry);
|
|
@@ -476,32 +575,59 @@ export const useStoryStore = create<StoryState>()(
|
|
|
476
575
|
(state.renderCounts[passageName] ?? 0) + 1;
|
|
477
576
|
});
|
|
478
577
|
|
|
578
|
+
// Watchers react to the completed transition (visit counts, cleared
|
|
579
|
+
// temporaries). Like beforenavigate changes, their run actions belong
|
|
580
|
+
// to the entered moment; navigations they request run afterwards.
|
|
581
|
+
const enteredVars = get().variables;
|
|
582
|
+
navigationTriggerPhase = true;
|
|
583
|
+
try {
|
|
584
|
+
checkTriggersOnNavigation();
|
|
585
|
+
} finally {
|
|
586
|
+
navigationTriggerPhase = false;
|
|
587
|
+
}
|
|
588
|
+
const deferred = deferredNavigations;
|
|
589
|
+
deferredNavigations = [];
|
|
590
|
+
if (get().variables !== enteredVars) {
|
|
591
|
+
rerecordNewestMoment(get().variables);
|
|
592
|
+
}
|
|
593
|
+
const prng = snapshotPRNG();
|
|
594
|
+
const recorded = get().history[get().historyIndex]!.prng;
|
|
595
|
+
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
596
|
+
set((state) => {
|
|
597
|
+
state.history[state.historyIndex]!.prng = prng;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
479
601
|
lastNavigationVars = get().variables;
|
|
480
602
|
persistSession(get);
|
|
481
603
|
|
|
482
604
|
emit('afternavigate', passageName, previousPassage);
|
|
605
|
+
|
|
606
|
+
for (const next of deferred) get().navigate(next);
|
|
483
607
|
},
|
|
484
608
|
|
|
485
609
|
goBack: () => {
|
|
486
|
-
const { historyIndex
|
|
610
|
+
const { historyIndex } = get();
|
|
487
611
|
if (historyIndex <= 0) return;
|
|
488
612
|
|
|
489
613
|
const previousPassage = get().currentPassage;
|
|
490
614
|
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
491
615
|
emit('beforenavigate', targetPassage);
|
|
492
616
|
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
);
|
|
617
|
+
// Restore the recorded snapshot; live variables may hold edits made
|
|
618
|
+
// since the current moment was recorded.
|
|
619
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
497
620
|
|
|
498
621
|
set((state) => {
|
|
499
622
|
state.historyIndex--;
|
|
500
623
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
624
|
+
state.navigationId++;
|
|
501
625
|
state.variables = restoredVars;
|
|
502
626
|
state.temporary = {};
|
|
503
627
|
});
|
|
504
628
|
|
|
629
|
+
// Restored state is not a change watchers react to
|
|
630
|
+
reinitTriggerState();
|
|
505
631
|
lastNavigationVars = get().variables;
|
|
506
632
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
507
633
|
persistSession(get);
|
|
@@ -510,25 +636,27 @@ export const useStoryStore = create<StoryState>()(
|
|
|
510
636
|
},
|
|
511
637
|
|
|
512
638
|
goForward: () => {
|
|
513
|
-
const { historyIndex, history: hist
|
|
639
|
+
const { historyIndex, history: hist } = get();
|
|
514
640
|
if (historyIndex >= hist.length - 1) return;
|
|
515
641
|
|
|
516
642
|
const previousPassage = get().currentPassage;
|
|
517
643
|
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
518
644
|
emit('beforenavigate', targetPassage);
|
|
519
645
|
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
);
|
|
646
|
+
// Restore the recorded snapshot; live variables may hold edits made
|
|
647
|
+
// since the current moment was recorded.
|
|
648
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
524
649
|
|
|
525
650
|
set((state) => {
|
|
526
651
|
state.historyIndex++;
|
|
527
652
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
653
|
+
state.navigationId++;
|
|
528
654
|
state.variables = restoredVars;
|
|
529
655
|
state.temporary = {};
|
|
530
656
|
});
|
|
531
657
|
|
|
658
|
+
// Restored state is not a change watchers react to
|
|
659
|
+
reinitTriggerState();
|
|
532
660
|
lastNavigationVars = get().variables;
|
|
533
661
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
534
662
|
persistSession(get);
|
|
@@ -572,6 +700,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
572
700
|
});
|
|
573
701
|
},
|
|
574
702
|
|
|
703
|
+
updateVariables: (recipe: (draft: VariableNamespaces) => void) => {
|
|
704
|
+
set((state) => {
|
|
705
|
+
recipe(state);
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
|
|
575
709
|
trackRender: (passageName: string) => {
|
|
576
710
|
set((state) => {
|
|
577
711
|
state.renderCounts[passageName] =
|
|
@@ -606,6 +740,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
606
740
|
|
|
607
741
|
set((state) => {
|
|
608
742
|
state.currentPassage = startPassage.name;
|
|
743
|
+
state.navigationId++;
|
|
609
744
|
state.variables = initialVars;
|
|
610
745
|
state.transient = deepClone(transientDefaults);
|
|
611
746
|
state.temporary = {};
|
|
@@ -648,30 +783,29 @@ export const useStoryStore = create<StoryState>()(
|
|
|
648
783
|
const { storyData, playthroughId } = get();
|
|
649
784
|
if (!storyData) return;
|
|
650
785
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
786
|
+
saveWithHooks(
|
|
787
|
+
slot,
|
|
788
|
+
custom,
|
|
789
|
+
() => get().getSavePayload(),
|
|
790
|
+
async (payload) => {
|
|
791
|
+
set((state) => {
|
|
792
|
+
state.saveError = null;
|
|
793
|
+
});
|
|
794
|
+
await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
|
|
660
795
|
set((state) => {
|
|
661
796
|
state.knownSaves = {
|
|
662
797
|
...state.knownSaves,
|
|
663
798
|
[slot ?? '']: true,
|
|
664
799
|
};
|
|
665
800
|
});
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
err instanceof Error ? err.message : 'Failed to save';
|
|
673
|
-
});
|
|
801
|
+
},
|
|
802
|
+
).catch((err) => {
|
|
803
|
+
console.error('spindle: failed to save', err);
|
|
804
|
+
set((state) => {
|
|
805
|
+
state.saveError =
|
|
806
|
+
err instanceof Error ? err.message : 'Failed to save';
|
|
674
807
|
});
|
|
808
|
+
});
|
|
675
809
|
},
|
|
676
810
|
|
|
677
811
|
load: (slot?: string) => {
|
|
@@ -684,7 +818,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
684
818
|
loadQuickSave(storyData.ifid, slot)
|
|
685
819
|
.then((payload) => {
|
|
686
820
|
if (!payload) return;
|
|
687
|
-
get().loadFromPayload(payload);
|
|
821
|
+
get().loadFromPayload(payload, slot);
|
|
688
822
|
})
|
|
689
823
|
.catch((err) => {
|
|
690
824
|
console.error('spindle: failed to load save', err);
|
|
@@ -832,41 +966,49 @@ export const useStoryStore = create<StoryState>()(
|
|
|
832
966
|
};
|
|
833
967
|
},
|
|
834
968
|
|
|
835
|
-
loadFromPayload: (payload: SavePayload) => {
|
|
969
|
+
loadFromPayload: (payload: SavePayload, slot?: string) => {
|
|
836
970
|
if (payload.history.length === 0) {
|
|
837
971
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
838
972
|
return;
|
|
839
973
|
}
|
|
840
974
|
|
|
841
|
-
emit('beforeload',
|
|
975
|
+
emit('beforeload', slot);
|
|
842
976
|
|
|
977
|
+
// Restore the state on entering the saved passage, not the payload's
|
|
978
|
+
// live variables: the passage remounts and runs its {set}/{do} again,
|
|
979
|
+
// so restoring their results as well would apply them twice. Changes
|
|
980
|
+
// made after entering it (input, clicks) are not restored, as with
|
|
981
|
+
// back/forward.
|
|
982
|
+
const entry = loadedEntryMoment(payload);
|
|
983
|
+
|
|
984
|
+
// The payload is already live (deserialized at the storage boundary by
|
|
985
|
+
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
843
986
|
// Convert full snapshots to patch entries
|
|
844
|
-
const base =
|
|
845
|
-
string,
|
|
846
|
-
unknown
|
|
847
|
-
>;
|
|
987
|
+
const base = deepClone(payload.history[0]?.variables ?? {});
|
|
848
988
|
const newPatchEntries: PatchEntry[] = [];
|
|
849
989
|
|
|
850
990
|
let prevVars: Record<string, unknown> = base;
|
|
851
991
|
for (let i = 1; i < payload.history.length; i++) {
|
|
852
|
-
const currVars =
|
|
853
|
-
string,
|
|
854
|
-
unknown
|
|
855
|
-
>;
|
|
992
|
+
const currVars = deepClone(payload.history[i]!.variables);
|
|
856
993
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
857
994
|
prevVars = currVars;
|
|
858
995
|
}
|
|
859
996
|
|
|
860
997
|
variableBase = deepClone(base);
|
|
861
998
|
patchEntries = newPatchEntries;
|
|
862
|
-
|
|
999
|
+
// Seed the session cache from the payload's own snapshots, so
|
|
1000
|
+
// persistSession does not rebuild them from the live variables.
|
|
1001
|
+
serializedHistory = payload.history.map((m) => ({
|
|
1002
|
+
passage: m.passage,
|
|
1003
|
+
variables: serialize(m.variables),
|
|
1004
|
+
timestamp: m.timestamp,
|
|
1005
|
+
prng: m.prng,
|
|
1006
|
+
}));
|
|
863
1007
|
|
|
864
1008
|
set((state) => {
|
|
865
1009
|
state.currentPassage = payload.passage;
|
|
866
|
-
state.
|
|
867
|
-
|
|
868
|
-
unknown
|
|
869
|
-
>;
|
|
1010
|
+
state.navigationId++;
|
|
1011
|
+
state.variables = deepClone(entry?.variables ?? payload.variables);
|
|
870
1012
|
state.history = payload.history.map((m) => ({
|
|
871
1013
|
passage: m.passage,
|
|
872
1014
|
timestamp: m.timestamp,
|
|
@@ -882,15 +1024,26 @@ export const useStoryStore = create<StoryState>()(
|
|
|
882
1024
|
state.transient = deepClone(get().transientDefaults);
|
|
883
1025
|
});
|
|
884
1026
|
|
|
885
|
-
|
|
1027
|
+
// Loaded state is not a change watchers react to
|
|
1028
|
+
reinitTriggerState();
|
|
886
1029
|
|
|
887
|
-
|
|
888
|
-
|
|
1030
|
+
// The next navigate() diffs from the snapshot recorded for the current
|
|
1031
|
+
// moment (the live variables, unless the load fell back to them)
|
|
1032
|
+
lastNavigationVars = reconstructVarsAt(get().historyIndex);
|
|
1033
|
+
|
|
1034
|
+
// Replay the passage's random rolls from its entry PRNG state; saves
|
|
1035
|
+
// whose moments predate PRNG snapshots use the payload's.
|
|
1036
|
+
const prng = entry?.prng !== undefined ? entry.prng : payload.prng;
|
|
1037
|
+
if (prng) {
|
|
1038
|
+
restorePRNG(prng.seed, prng.pull);
|
|
889
1039
|
} else {
|
|
890
1040
|
resetPRNG();
|
|
891
1041
|
}
|
|
892
1042
|
|
|
893
|
-
|
|
1043
|
+
// Write the loaded game to the session so a refresh restores it
|
|
1044
|
+
persistSession(get);
|
|
1045
|
+
|
|
1046
|
+
emit('afterload', slot);
|
|
894
1047
|
},
|
|
895
1048
|
|
|
896
1049
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|
package/src/story-api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore, trackRuntimeUnsub } from './store';
|
|
2
|
+
import type { VariableNamespaces } from './store';
|
|
2
3
|
import {
|
|
3
4
|
on as emitterOn,
|
|
4
5
|
emit,
|
|
@@ -29,6 +30,7 @@ import type { MacroDefinition } from './define-macro';
|
|
|
29
30
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
30
31
|
import type { MacroMetadata } from './registry';
|
|
31
32
|
import { getActions, getAction, type StoryAction } from './action-registry';
|
|
33
|
+
import { getRenderedPassage } from './passage-render-state';
|
|
32
34
|
import {
|
|
33
35
|
initPRNG,
|
|
34
36
|
isPRNGEnabled,
|
|
@@ -284,23 +286,15 @@ function warnIfUndeclared(isTransient: boolean, key: string): void {
|
|
|
284
286
|
);
|
|
285
287
|
}
|
|
286
288
|
|
|
287
|
-
/** Set a single variable, resolving dot-paths if present. */
|
|
288
|
-
function setOne(name: string, value: unknown): void {
|
|
289
|
+
/** Set a single variable on an Immer draft, resolving dot-paths if present. */
|
|
290
|
+
function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
|
|
289
291
|
const { isTransient, key } = parseName(name);
|
|
290
|
-
const namespace = isTransient ?
|
|
291
|
-
warnIfUndeclared(isTransient, key);
|
|
292
|
+
const namespace = isTransient ? draft.transient : draft.variables;
|
|
292
293
|
|
|
293
294
|
if (key.includes('.')) {
|
|
294
|
-
|
|
295
|
-
setByPath(state[namespace] as Record<string, unknown>, key, value);
|
|
296
|
-
});
|
|
295
|
+
setByPath(namespace, key, value);
|
|
297
296
|
} else {
|
|
298
|
-
|
|
299
|
-
if (isTransient) {
|
|
300
|
-
state.setTransient(key, value);
|
|
301
|
-
} else {
|
|
302
|
-
state.setVariable(key, value);
|
|
303
|
-
}
|
|
297
|
+
namespace[key] = value;
|
|
304
298
|
}
|
|
305
299
|
}
|
|
306
300
|
|
|
@@ -315,13 +309,22 @@ function createStoryAPI(): StoryAPI {
|
|
|
315
309
|
},
|
|
316
310
|
|
|
317
311
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
312
|
+
const names =
|
|
313
|
+
typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
|
|
314
|
+
for (const name of names) {
|
|
315
|
+
const { isTransient, key } = parseName(name);
|
|
316
|
+
warnIfUndeclared(isTransient, key);
|
|
324
317
|
}
|
|
318
|
+
// One store update for all keys, so watchers see them together
|
|
319
|
+
useStoryStore.getState().updateVariables((draft) => {
|
|
320
|
+
if (typeof nameOrVars === 'string') {
|
|
321
|
+
setOne(draft, nameOrVars, value);
|
|
322
|
+
} else {
|
|
323
|
+
for (const [k, v] of Object.entries(nameOrVars)) {
|
|
324
|
+
setOne(draft, k, v);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
325
328
|
},
|
|
326
329
|
|
|
327
330
|
goto(passageName: string): void {
|
|
@@ -539,14 +542,26 @@ function createStoryAPI(): StoryAPI {
|
|
|
539
542
|
return unsub;
|
|
540
543
|
},
|
|
541
544
|
|
|
542
|
-
waitForActions(): Promise<StoryAction[]> {
|
|
543
|
-
|
|
544
|
-
requestAnimationFrame(() =>
|
|
545
|
-
|
|
546
|
-
|
|
545
|
+
async waitForActions(): Promise<StoryAction[]> {
|
|
546
|
+
await new Promise<void>((resolve) => {
|
|
547
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
548
|
+
});
|
|
549
|
+
// A navigation can still be rendering: the passage display mounts the
|
|
550
|
+
// new passage from an effect, after a fade-through's outgoing phase.
|
|
551
|
+
// Its actions exist only once it is mounted, so wait for that.
|
|
552
|
+
const { currentPassage, renderDeferred } = useStoryStore.getState();
|
|
553
|
+
const rendered = getRenderedPassage();
|
|
554
|
+
if (rendered !== null && rendered !== currentPassage && !renderDeferred) {
|
|
555
|
+
await new Promise<void>((resolve) => {
|
|
556
|
+
const off = emitterOn('passagerender', (name) => {
|
|
557
|
+
if (name === useStoryStore.getState().currentPassage) {
|
|
558
|
+
off();
|
|
559
|
+
resolve();
|
|
560
|
+
}
|
|
547
561
|
});
|
|
548
562
|
});
|
|
549
|
-
}
|
|
563
|
+
}
|
|
564
|
+
return getActions();
|
|
550
565
|
},
|
|
551
566
|
|
|
552
567
|
watch(
|