@rohal12/spindle 0.50.0 → 0.51.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/dist/pkg/headless.js +852 -254
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +1 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +21 -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/index.tsx +13 -19
- package/src/interpolation.ts +4 -9
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/saves/save-manager.ts +97 -39
- package/src/settings.ts +15 -0
- package/src/store.ts +266 -87
- package/src/story-api.ts +30 -28
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/utils/live-locals.ts +22 -0
- package/types/index.d.ts +16 -6
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,21 +363,30 @@ 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
|
-
save: (slot?: string, custom?: Record<string, unknown>) => void
|
|
293
|
-
load: (slot?: string) => void
|
|
373
|
+
save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
|
|
374
|
+
load: (slot?: string) => Promise<void>;
|
|
294
375
|
hasSave: (slot?: string) => boolean;
|
|
295
376
|
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
|
|
296
377
|
listSaves: () => Promise<SaveInfo[]>;
|
|
297
|
-
deleteSave: (slot?: string) => void
|
|
378
|
+
deleteSave: (slot?: string) => Promise<void>;
|
|
298
379
|
exportSave: (slot?: string) => Promise<SaveExport | null>;
|
|
299
380
|
importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
|
|
300
381
|
clearGameData: () => void;
|
|
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;
|
|
@@ -310,10 +395,22 @@ export interface StoryState {
|
|
|
310
395
|
clearDeferredRender: () => void;
|
|
311
396
|
}
|
|
312
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Return `p` marked as handled: a caller that ignores the result of a
|
|
400
|
+
* fire-and-forget operation (whose failure is already logged) gets no
|
|
401
|
+
* unhandled-rejection report, while a caller that awaits it still sees the
|
|
402
|
+
* error.
|
|
403
|
+
*/
|
|
404
|
+
function handled<T>(p: Promise<T>): Promise<T> {
|
|
405
|
+
p.catch(() => {});
|
|
406
|
+
return p;
|
|
407
|
+
}
|
|
408
|
+
|
|
313
409
|
export const useStoryStore = create<StoryState>()(
|
|
314
410
|
immer((set, get) => ({
|
|
315
411
|
storyData: null,
|
|
316
412
|
currentPassage: '',
|
|
413
|
+
navigationId: 0,
|
|
317
414
|
variables: {},
|
|
318
415
|
variableDefaults: {},
|
|
319
416
|
transient: {},
|
|
@@ -371,6 +468,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
371
468
|
set((state) => {
|
|
372
469
|
state.storyData = storyData as StoryData;
|
|
373
470
|
state.currentPassage = startPassage.name;
|
|
471
|
+
state.navigationId++;
|
|
374
472
|
state.variables = initialVars;
|
|
375
473
|
state.variableDefaults = variableDefaults;
|
|
376
474
|
state.transient = deepClone(transientDefaults);
|
|
@@ -420,7 +518,14 @@ export const useStoryStore = create<StoryState>()(
|
|
|
420
518
|
},
|
|
421
519
|
|
|
422
520
|
navigate: (passageName: string) => {
|
|
423
|
-
|
|
521
|
+
// A watcher (goto action or callback) reacting to a navigation must not
|
|
522
|
+
// start another one before the first has recorded its moment.
|
|
523
|
+
if (navigationTriggerPhase) {
|
|
524
|
+
deferredNavigations.push(passageName);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const { storyData } = get();
|
|
424
529
|
if (!storyData) return;
|
|
425
530
|
|
|
426
531
|
if (SPECIAL_PASSAGES.has(passageName)) {
|
|
@@ -438,16 +543,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
438
543
|
const previousPassage = get().currentPassage;
|
|
439
544
|
emit('beforenavigate', passageName);
|
|
440
545
|
|
|
441
|
-
// Compute variable delta before Immer set()
|
|
442
|
-
|
|
546
|
+
// Compute variable delta before Immer set(). Read the variables after
|
|
547
|
+
// beforenavigate so changes made by its handlers are recorded.
|
|
548
|
+
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
443
549
|
|
|
444
550
|
set((state) => {
|
|
445
551
|
state.temporary = {};
|
|
446
552
|
state.currentPassage = passageName;
|
|
553
|
+
state.navigationId++;
|
|
447
554
|
|
|
448
555
|
// Truncate forward history if we navigated back then chose a new path
|
|
449
556
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
450
557
|
patchEntries.length = state.historyIndex;
|
|
558
|
+
if (serializedHistory.length > state.historyIndex + 1) {
|
|
559
|
+
serializedHistory.length = state.historyIndex + 1;
|
|
560
|
+
}
|
|
451
561
|
|
|
452
562
|
// Push new transition and moment
|
|
453
563
|
patchEntries.push(patchEntry);
|
|
@@ -476,32 +586,59 @@ export const useStoryStore = create<StoryState>()(
|
|
|
476
586
|
(state.renderCounts[passageName] ?? 0) + 1;
|
|
477
587
|
});
|
|
478
588
|
|
|
589
|
+
// Watchers react to the completed transition (visit counts, cleared
|
|
590
|
+
// temporaries). Like beforenavigate changes, their run actions belong
|
|
591
|
+
// to the entered moment; navigations they request run afterwards.
|
|
592
|
+
const enteredVars = get().variables;
|
|
593
|
+
navigationTriggerPhase = true;
|
|
594
|
+
try {
|
|
595
|
+
checkTriggersOnNavigation();
|
|
596
|
+
} finally {
|
|
597
|
+
navigationTriggerPhase = false;
|
|
598
|
+
}
|
|
599
|
+
const deferred = deferredNavigations;
|
|
600
|
+
deferredNavigations = [];
|
|
601
|
+
if (get().variables !== enteredVars) {
|
|
602
|
+
rerecordNewestMoment(get().variables);
|
|
603
|
+
}
|
|
604
|
+
const prng = snapshotPRNG();
|
|
605
|
+
const recorded = get().history[get().historyIndex]!.prng;
|
|
606
|
+
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
|
|
607
|
+
set((state) => {
|
|
608
|
+
state.history[state.historyIndex]!.prng = prng;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
479
612
|
lastNavigationVars = get().variables;
|
|
480
613
|
persistSession(get);
|
|
481
614
|
|
|
482
615
|
emit('afternavigate', passageName, previousPassage);
|
|
616
|
+
|
|
617
|
+
for (const next of deferred) get().navigate(next);
|
|
483
618
|
},
|
|
484
619
|
|
|
485
620
|
goBack: () => {
|
|
486
|
-
const { historyIndex
|
|
621
|
+
const { historyIndex } = get();
|
|
487
622
|
if (historyIndex <= 0) return;
|
|
488
623
|
|
|
489
624
|
const previousPassage = get().currentPassage;
|
|
490
625
|
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
491
626
|
emit('beforenavigate', targetPassage);
|
|
492
627
|
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
);
|
|
628
|
+
// Restore the recorded snapshot; live variables may hold edits made
|
|
629
|
+
// since the current moment was recorded.
|
|
630
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
497
631
|
|
|
498
632
|
set((state) => {
|
|
499
633
|
state.historyIndex--;
|
|
500
634
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
635
|
+
state.navigationId++;
|
|
501
636
|
state.variables = restoredVars;
|
|
502
637
|
state.temporary = {};
|
|
503
638
|
});
|
|
504
639
|
|
|
640
|
+
// Restored state is not a change watchers react to
|
|
641
|
+
reinitTriggerState();
|
|
505
642
|
lastNavigationVars = get().variables;
|
|
506
643
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
507
644
|
persistSession(get);
|
|
@@ -510,25 +647,27 @@ export const useStoryStore = create<StoryState>()(
|
|
|
510
647
|
},
|
|
511
648
|
|
|
512
649
|
goForward: () => {
|
|
513
|
-
const { historyIndex, history: hist
|
|
650
|
+
const { historyIndex, history: hist } = get();
|
|
514
651
|
if (historyIndex >= hist.length - 1) return;
|
|
515
652
|
|
|
516
653
|
const previousPassage = get().currentPassage;
|
|
517
654
|
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
518
655
|
emit('beforenavigate', targetPassage);
|
|
519
656
|
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
);
|
|
657
|
+
// Restore the recorded snapshot; live variables may hold edits made
|
|
658
|
+
// since the current moment was recorded.
|
|
659
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
524
660
|
|
|
525
661
|
set((state) => {
|
|
526
662
|
state.historyIndex++;
|
|
527
663
|
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
664
|
+
state.navigationId++;
|
|
528
665
|
state.variables = restoredVars;
|
|
529
666
|
state.temporary = {};
|
|
530
667
|
});
|
|
531
668
|
|
|
669
|
+
// Restored state is not a change watchers react to
|
|
670
|
+
reinitTriggerState();
|
|
532
671
|
lastNavigationVars = get().variables;
|
|
533
672
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
534
673
|
persistSession(get);
|
|
@@ -572,6 +711,12 @@ export const useStoryStore = create<StoryState>()(
|
|
|
572
711
|
});
|
|
573
712
|
},
|
|
574
713
|
|
|
714
|
+
updateVariables: (recipe: (draft: VariableNamespaces) => void) => {
|
|
715
|
+
set((state) => {
|
|
716
|
+
recipe(state);
|
|
717
|
+
});
|
|
718
|
+
},
|
|
719
|
+
|
|
575
720
|
trackRender: (passageName: string) => {
|
|
576
721
|
set((state) => {
|
|
577
722
|
state.renderCounts[passageName] =
|
|
@@ -606,6 +751,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
606
751
|
|
|
607
752
|
set((state) => {
|
|
608
753
|
state.currentPassage = startPassage.name;
|
|
754
|
+
state.navigationId++;
|
|
609
755
|
state.variables = initialVars;
|
|
610
756
|
state.transient = deepClone(transientDefaults);
|
|
611
757
|
state.temporary = {};
|
|
@@ -646,53 +792,64 @@ export const useStoryStore = create<StoryState>()(
|
|
|
646
792
|
|
|
647
793
|
save: (slot?: string, custom?: Record<string, unknown>) => {
|
|
648
794
|
const { storyData, playthroughId } = get();
|
|
649
|
-
if (!storyData) return;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
795
|
+
if (!storyData) return Promise.resolve();
|
|
796
|
+
|
|
797
|
+
return handled(
|
|
798
|
+
saveWithHooks(
|
|
799
|
+
slot,
|
|
800
|
+
custom,
|
|
801
|
+
() => get().getSavePayload(),
|
|
802
|
+
async (payload) => {
|
|
803
|
+
set((state) => {
|
|
804
|
+
state.saveError = null;
|
|
805
|
+
});
|
|
806
|
+
await quickSave(
|
|
807
|
+
storyData.ifid,
|
|
808
|
+
playthroughId,
|
|
809
|
+
payload,
|
|
810
|
+
slot,
|
|
811
|
+
custom,
|
|
812
|
+
);
|
|
813
|
+
set((state) => {
|
|
814
|
+
state.knownSaves = {
|
|
815
|
+
...state.knownSaves,
|
|
816
|
+
[slot ?? '']: true,
|
|
817
|
+
};
|
|
818
|
+
});
|
|
819
|
+
},
|
|
820
|
+
).catch((err) => {
|
|
669
821
|
console.error('spindle: failed to save', err);
|
|
670
822
|
set((state) => {
|
|
671
823
|
state.saveError =
|
|
672
824
|
err instanceof Error ? err.message : 'Failed to save';
|
|
673
825
|
});
|
|
674
|
-
|
|
826
|
+
throw err;
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
675
829
|
},
|
|
676
830
|
|
|
677
831
|
load: (slot?: string) => {
|
|
678
832
|
const { storyData } = get();
|
|
679
|
-
if (!storyData) return;
|
|
833
|
+
if (!storyData) return Promise.resolve();
|
|
680
834
|
|
|
681
835
|
set((state) => {
|
|
682
836
|
state.loadError = null;
|
|
683
837
|
});
|
|
684
|
-
|
|
685
|
-
.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
state
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
838
|
+
return handled(
|
|
839
|
+
loadQuickSave(storyData.ifid, slot)
|
|
840
|
+
.then((payload) => {
|
|
841
|
+
if (!payload) return;
|
|
842
|
+
get().loadFromPayload(payload, slot);
|
|
843
|
+
})
|
|
844
|
+
.catch((err) => {
|
|
845
|
+
console.error('spindle: failed to load save', err);
|
|
846
|
+
set((state) => {
|
|
847
|
+
state.loadError =
|
|
848
|
+
err instanceof Error ? err.message : 'Failed to load';
|
|
849
|
+
});
|
|
850
|
+
throw err;
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
696
853
|
},
|
|
697
854
|
|
|
698
855
|
hasSave: (slot?: string) => {
|
|
@@ -715,19 +872,22 @@ export const useStoryStore = create<StoryState>()(
|
|
|
715
872
|
|
|
716
873
|
deleteSave: (slot?: string) => {
|
|
717
874
|
const { storyData } = get();
|
|
718
|
-
if (!storyData) return;
|
|
875
|
+
if (!storyData) return Promise.resolve();
|
|
719
876
|
|
|
720
|
-
|
|
721
|
-
.
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
877
|
+
return handled(
|
|
878
|
+
deleteSlotSave(storyData.ifid, slot)
|
|
879
|
+
.then(() => {
|
|
880
|
+
set((state) => {
|
|
881
|
+
const key = slot ?? '';
|
|
882
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
883
|
+
state.knownSaves = rest as Record<string, true>;
|
|
884
|
+
});
|
|
885
|
+
})
|
|
886
|
+
.catch((err) => {
|
|
887
|
+
console.error('spindle: failed to delete save', err);
|
|
888
|
+
throw err;
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
731
891
|
},
|
|
732
892
|
|
|
733
893
|
exportSave: async (slot?: string): Promise<SaveExport | null> => {
|
|
@@ -832,41 +992,49 @@ export const useStoryStore = create<StoryState>()(
|
|
|
832
992
|
};
|
|
833
993
|
},
|
|
834
994
|
|
|
835
|
-
loadFromPayload: (payload: SavePayload) => {
|
|
995
|
+
loadFromPayload: (payload: SavePayload, slot?: string) => {
|
|
836
996
|
if (payload.history.length === 0) {
|
|
837
997
|
console.warn('loadFromPayload: rejecting payload with empty history');
|
|
838
998
|
return;
|
|
839
999
|
}
|
|
840
1000
|
|
|
841
|
-
emit('beforeload',
|
|
1001
|
+
emit('beforeload', slot);
|
|
842
1002
|
|
|
1003
|
+
// Restore the state on entering the saved passage, not the payload's
|
|
1004
|
+
// live variables: the passage remounts and runs its {set}/{do} again,
|
|
1005
|
+
// so restoring their results as well would apply them twice. Changes
|
|
1006
|
+
// made after entering it (input, clicks) are not restored, as with
|
|
1007
|
+
// back/forward.
|
|
1008
|
+
const entry = loadedEntryMoment(payload);
|
|
1009
|
+
|
|
1010
|
+
// The payload is already live (deserialized at the storage boundary by
|
|
1011
|
+
// loadSave/loadSession); deserializing again would corrupt built-ins.
|
|
843
1012
|
// Convert full snapshots to patch entries
|
|
844
|
-
const base =
|
|
845
|
-
string,
|
|
846
|
-
unknown
|
|
847
|
-
>;
|
|
1013
|
+
const base = deepClone(payload.history[0]?.variables ?? {});
|
|
848
1014
|
const newPatchEntries: PatchEntry[] = [];
|
|
849
1015
|
|
|
850
1016
|
let prevVars: Record<string, unknown> = base;
|
|
851
1017
|
for (let i = 1; i < payload.history.length; i++) {
|
|
852
|
-
const currVars =
|
|
853
|
-
string,
|
|
854
|
-
unknown
|
|
855
|
-
>;
|
|
1018
|
+
const currVars = deepClone(payload.history[i]!.variables);
|
|
856
1019
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
857
1020
|
prevVars = currVars;
|
|
858
1021
|
}
|
|
859
1022
|
|
|
860
1023
|
variableBase = deepClone(base);
|
|
861
1024
|
patchEntries = newPatchEntries;
|
|
862
|
-
|
|
1025
|
+
// Seed the session cache from the payload's own snapshots, so
|
|
1026
|
+
// persistSession does not rebuild them from the live variables.
|
|
1027
|
+
serializedHistory = payload.history.map((m) => ({
|
|
1028
|
+
passage: m.passage,
|
|
1029
|
+
variables: serialize(m.variables),
|
|
1030
|
+
timestamp: m.timestamp,
|
|
1031
|
+
prng: m.prng,
|
|
1032
|
+
}));
|
|
863
1033
|
|
|
864
1034
|
set((state) => {
|
|
865
1035
|
state.currentPassage = payload.passage;
|
|
866
|
-
state.
|
|
867
|
-
|
|
868
|
-
unknown
|
|
869
|
-
>;
|
|
1036
|
+
state.navigationId++;
|
|
1037
|
+
state.variables = deepClone(entry?.variables ?? payload.variables);
|
|
870
1038
|
state.history = payload.history.map((m) => ({
|
|
871
1039
|
passage: m.passage,
|
|
872
1040
|
timestamp: m.timestamp,
|
|
@@ -882,15 +1050,26 @@ export const useStoryStore = create<StoryState>()(
|
|
|
882
1050
|
state.transient = deepClone(get().transientDefaults);
|
|
883
1051
|
});
|
|
884
1052
|
|
|
885
|
-
|
|
1053
|
+
// Loaded state is not a change watchers react to
|
|
1054
|
+
reinitTriggerState();
|
|
1055
|
+
|
|
1056
|
+
// The next navigate() diffs from the snapshot recorded for the current
|
|
1057
|
+
// moment (the live variables, unless the load fell back to them)
|
|
1058
|
+
lastNavigationVars = reconstructVarsAt(get().historyIndex);
|
|
886
1059
|
|
|
887
|
-
|
|
888
|
-
|
|
1060
|
+
// Replay the passage's random rolls from its entry PRNG state; saves
|
|
1061
|
+
// whose moments predate PRNG snapshots use the payload's.
|
|
1062
|
+
const prng = entry?.prng !== undefined ? entry.prng : payload.prng;
|
|
1063
|
+
if (prng) {
|
|
1064
|
+
restorePRNG(prng.seed, prng.pull);
|
|
889
1065
|
} else {
|
|
890
1066
|
resetPRNG();
|
|
891
1067
|
}
|
|
892
1068
|
|
|
893
|
-
|
|
1069
|
+
// Write the loaded game to the session so a refresh restores it
|
|
1070
|
+
persistSession(get);
|
|
1071
|
+
|
|
1072
|
+
emit('afterload', slot);
|
|
894
1073
|
},
|
|
895
1074
|
|
|
896
1075
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|