@rohal12/spindle 0.52.3 → 0.52.5
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 +1105 -1588
- package/dist/pkg/story-variables.js +155 -469
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/components/macros/Widget.tsx +18 -7
- package/src/execute-mutation.ts +17 -97
- package/src/index.tsx +5 -8
- package/src/interpolation.ts +16 -22
- package/src/js-lexer.ts +20 -9
- package/src/markup/ast.ts +108 -147
- package/src/markup/code-attributes.ts +4 -6
- package/src/markup/tokenizer.ts +218 -574
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
package/src/store.ts
CHANGED
|
@@ -46,7 +46,8 @@ import {
|
|
|
46
46
|
clearAllData as smClearAllData,
|
|
47
47
|
deletePlaythroughData as smDeletePlaythroughData,
|
|
48
48
|
} from './saves/save-manager';
|
|
49
|
-
import {
|
|
49
|
+
import { serialize } from './class-registry';
|
|
50
|
+
import { deepClone, mergeKeys, mergesWith } from './structural';
|
|
50
51
|
import {
|
|
51
52
|
snapshotPRNG,
|
|
52
53
|
restorePRNG,
|
|
@@ -56,7 +57,6 @@ import {
|
|
|
56
57
|
} from './prng';
|
|
57
58
|
import { errorMessage } from './utils/error-message';
|
|
58
59
|
import {
|
|
59
|
-
isMergeable,
|
|
60
60
|
routeStoreUpdate,
|
|
61
61
|
runWithCommittedMutations,
|
|
62
62
|
} from './execute-mutation';
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
checkVariableName,
|
|
65
65
|
createCounts,
|
|
66
66
|
createNamespace,
|
|
67
|
+
hasOwn,
|
|
67
68
|
isNamespace,
|
|
68
69
|
type Counts,
|
|
69
70
|
type Namespace,
|
|
@@ -127,7 +128,7 @@ function computeVarPatches(
|
|
|
127
128
|
for (const key of Object.keys(d)) {
|
|
128
129
|
// Own keys only: `curr` may be a plain object (a loaded snapshot),
|
|
129
130
|
// whose inherited `constructor` is no variable
|
|
130
|
-
if (!
|
|
131
|
+
if (!hasOwn(curr, key)) delete d[key];
|
|
131
132
|
}
|
|
132
133
|
for (const [key, val] of Object.entries(curr)) {
|
|
133
134
|
d[key] = val;
|
|
@@ -149,6 +150,31 @@ function rerecordNewestMoment(vars: Record<string, unknown>): void {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Call `fn` with the variables recorded for each of the first `count`
|
|
155
|
+
* history moments, replaying the patches once.
|
|
156
|
+
*/
|
|
157
|
+
function forEachRecorded(
|
|
158
|
+
count: number,
|
|
159
|
+
fn: (vars: Record<string, unknown>, index: number) => void,
|
|
160
|
+
): void {
|
|
161
|
+
let vars: Record<string, unknown> = variableBase;
|
|
162
|
+
for (let i = 0; i < count; i++) {
|
|
163
|
+
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
164
|
+
fn(vars, i);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A history moment as saves and the session hold it, with its variables. */
|
|
169
|
+
function savedMoment<V>(moment: HistoryMoment, variables: V) {
|
|
170
|
+
return {
|
|
171
|
+
passage: moment.passage,
|
|
172
|
+
variables,
|
|
173
|
+
timestamp: moment.timestamp,
|
|
174
|
+
prng: moment.prng,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
/** Reconstruct variables at a given history moment by replaying patches. */
|
|
153
179
|
function reconstructVarsAt(index: number): Record<string, unknown> {
|
|
154
180
|
let vars: Record<string, unknown> = variableBase;
|
|
@@ -188,26 +214,14 @@ function persistSession(get: () => StoryState): void {
|
|
|
188
214
|
if (gap === 1) {
|
|
189
215
|
// Common path: one new moment at the end — use current variables directly
|
|
190
216
|
const i = history.length - 1;
|
|
191
|
-
serializedHistory[i] =
|
|
192
|
-
passage: history[i]!.passage,
|
|
193
|
-
variables: serialize(variables),
|
|
194
|
-
timestamp: history[i]!.timestamp,
|
|
195
|
-
prng: history[i]!.prng,
|
|
196
|
-
};
|
|
217
|
+
serializedHistory[i] = savedMoment(history[i]!, serialize(variables));
|
|
197
218
|
} else {
|
|
198
219
|
// Bulk fill (after loadFromPayload) — reconstruct incrementally
|
|
199
|
-
|
|
200
|
-
for (let i = 0; i < history.length; i++) {
|
|
201
|
-
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
220
|
+
forEachRecorded(history.length, (vars, i) => {
|
|
202
221
|
if (i >= serializedHistory.length) {
|
|
203
|
-
serializedHistory[i] =
|
|
204
|
-
passage: history[i]!.passage,
|
|
205
|
-
variables: serialize(vars),
|
|
206
|
-
timestamp: history[i]!.timestamp,
|
|
207
|
-
prng: history[i]!.prng,
|
|
208
|
-
};
|
|
222
|
+
serializedHistory[i] = savedMoment(history[i]!, serialize(vars));
|
|
209
223
|
}
|
|
210
|
-
}
|
|
224
|
+
});
|
|
211
225
|
}
|
|
212
226
|
}
|
|
213
227
|
|
|
@@ -250,6 +264,9 @@ function trimHistory(state: {
|
|
|
250
264
|
return true;
|
|
251
265
|
}
|
|
252
266
|
|
|
267
|
+
type StoreGet = () => StoryState;
|
|
268
|
+
type StoreSet = (recipe: (state: StoryState) => void) => void;
|
|
269
|
+
|
|
253
270
|
/** True while navigate() lets watchers react to the moment it entered. */
|
|
254
271
|
let navigationTriggerPhase = false;
|
|
255
272
|
|
|
@@ -272,10 +289,7 @@ let enteredMoment: {
|
|
|
272
289
|
* leave the moment: a watcher that moves through history must not have the
|
|
273
290
|
* moment it leaves recorded with the state of the one it arrives at.
|
|
274
291
|
*/
|
|
275
|
-
function finishEnteredMoment(
|
|
276
|
-
get: () => StoryState,
|
|
277
|
-
set: (recipe: (state: StoryState) => void) => void,
|
|
278
|
-
): void {
|
|
292
|
+
function finishEnteredMoment(get: StoreGet, set: StoreSet): void {
|
|
279
293
|
const moment = enteredMoment;
|
|
280
294
|
enteredMoment = null;
|
|
281
295
|
if (!moment || get().navigationId !== moment.navigationId) return;
|
|
@@ -310,6 +324,69 @@ function resetModuleState(base: Namespace): void {
|
|
|
310
324
|
serializedHistory = [];
|
|
311
325
|
}
|
|
312
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Fresh variables from `defaults` for a game that starts over (init,
|
|
329
|
+
* restart), recorded as the history base.
|
|
330
|
+
*/
|
|
331
|
+
function startVariables(defaults: Record<string, unknown>): Namespace {
|
|
332
|
+
const initialVars = createNamespace(deepClone(defaults));
|
|
333
|
+
resetModuleState(deepClone(initialVars));
|
|
334
|
+
return initialVars;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Enter the start passage with `variables`, as the only history moment
|
|
339
|
+
* (init, restart). Call it inside a store update.
|
|
340
|
+
*/
|
|
341
|
+
function enterStart(
|
|
342
|
+
state: StoryState,
|
|
343
|
+
passage: string,
|
|
344
|
+
variables: Namespace,
|
|
345
|
+
transientDefaults: Record<string, unknown>,
|
|
346
|
+
): void {
|
|
347
|
+
state.currentPassage = passage;
|
|
348
|
+
state.navigationId++;
|
|
349
|
+
state.variables = variables;
|
|
350
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
351
|
+
state.temporary = createNamespace();
|
|
352
|
+
state.history = [{ passage, timestamp: Date.now() }];
|
|
353
|
+
state.historyIndex = 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Move through history by `step` moments (back or forward), restoring the
|
|
358
|
+
* snapshot recorded for the moment moved to: live variables may hold edits
|
|
359
|
+
* made since the current moment was recorded.
|
|
360
|
+
*/
|
|
361
|
+
function moveInHistory(get: StoreGet, set: StoreSet, step: -1 | 1): void {
|
|
362
|
+
const { historyIndex, history } = get();
|
|
363
|
+
const target = historyIndex + step;
|
|
364
|
+
if (target < 0 || target >= history.length) return;
|
|
365
|
+
finishEnteredMoment(get, set);
|
|
366
|
+
|
|
367
|
+
const previousPassage = get().currentPassage;
|
|
368
|
+
const targetPassage = history[target]!.passage;
|
|
369
|
+
emit('beforenavigate', targetPassage);
|
|
370
|
+
|
|
371
|
+
const restoredVars = deepClone(reconstructVarsAt(target));
|
|
372
|
+
|
|
373
|
+
set((state) => {
|
|
374
|
+
state.historyIndex += step;
|
|
375
|
+
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
376
|
+
state.navigationId++;
|
|
377
|
+
state.variables = restoredVars;
|
|
378
|
+
state.temporary = createNamespace();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Restored state is not a change watchers react to
|
|
382
|
+
reinitTriggerState();
|
|
383
|
+
lastNavigationVars = get().variables;
|
|
384
|
+
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
385
|
+
persistSession(get);
|
|
386
|
+
|
|
387
|
+
emit('afternavigate', targetPassage, previousPassage);
|
|
388
|
+
}
|
|
389
|
+
|
|
313
390
|
/**
|
|
314
391
|
* Record the state StoryInit left behind as the start moment: its variable
|
|
315
392
|
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
@@ -322,10 +399,7 @@ export function recordStoryInitState(): void {
|
|
|
322
399
|
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
323
400
|
if (history.length !== 1 || historyIndex !== 0) return;
|
|
324
401
|
|
|
325
|
-
|
|
326
|
-
patchEntries = [];
|
|
327
|
-
lastNavigationVars = variables;
|
|
328
|
-
serializedHistory = [];
|
|
402
|
+
resetModuleState(variables);
|
|
329
403
|
|
|
330
404
|
const prng = snapshotPRNG();
|
|
331
405
|
useStoryStore.setState((state) => {
|
|
@@ -554,65 +628,13 @@ function keepHookWrites(
|
|
|
554
628
|
mergeHookWrites(moment.variables, before, after, new Set());
|
|
555
629
|
}
|
|
556
630
|
|
|
557
|
-
const hasOwn = (obj: object, key: string): boolean =>
|
|
558
|
-
Object.prototype.hasOwnProperty.call(obj, key);
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Whether changes between `a` and `b` merge key by key: both are arrays,
|
|
562
|
-
* or both are objects of one class (see isMergeable).
|
|
563
|
-
*/
|
|
564
|
-
function mergeable(a: unknown, b: unknown): boolean {
|
|
565
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
566
|
-
return Array.isArray(a) && Array.isArray(b);
|
|
567
|
-
}
|
|
568
|
-
return (
|
|
569
|
-
isMergeable(a) &&
|
|
570
|
-
isMergeable(b) &&
|
|
571
|
-
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Apply the hooks' change of a value, from `before` to `after`, to the
|
|
577
|
-
* snapshot's copy of it, `target[key]`. Store updates are immutable, so a
|
|
578
|
-
* value no hook touched keeps its identity; one rebuilt with equal content
|
|
579
|
-
* (mutation code commits whole values) is no change either. Where `target`
|
|
580
|
-
* lacks the key or holds another kind of value (the passage created or
|
|
581
|
-
* replaced it), the hooks' whole value is written.
|
|
582
|
-
*/
|
|
583
|
-
function mergeHookWrite(
|
|
584
|
-
target: Record<string, unknown>,
|
|
585
|
-
key: string,
|
|
586
|
-
before: unknown,
|
|
587
|
-
after: unknown,
|
|
588
|
-
ancestors: Set<object>,
|
|
589
|
-
): void {
|
|
590
|
-
if (Object.is(before, after)) return;
|
|
591
|
-
if (
|
|
592
|
-
mergeable(before, after) &&
|
|
593
|
-
hasOwn(target, key) &&
|
|
594
|
-
mergeable(after, target[key]) &&
|
|
595
|
-
// Stop at cycles: deepEqual() and deepClone() handle them
|
|
596
|
-
!ancestors.has(after as object)
|
|
597
|
-
) {
|
|
598
|
-
mergeHookWrites(
|
|
599
|
-
target[key] as Record<string, unknown>,
|
|
600
|
-
before as Record<string, unknown>,
|
|
601
|
-
after as Record<string, unknown>,
|
|
602
|
-
ancestors,
|
|
603
|
-
);
|
|
604
|
-
} else if (!deepEqual(before, after)) {
|
|
605
|
-
target[key] = deepClone(after);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
631
|
/**
|
|
610
|
-
* Apply the changes between `before` and `after`, two objects or two
|
|
611
|
-
*
|
|
612
|
-
* index.
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
632
|
+
* Apply the hooks' changes between `before` and `after`, two objects or two
|
|
633
|
+
* arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
|
|
634
|
+
* elements merge by index). A changed value is merged key by key where the
|
|
635
|
+
* snapshot holds the same kind of value. Where it lacks the key or holds
|
|
636
|
+
* another kind (the passage created or replaced it), the hooks' whole value
|
|
637
|
+
* is written.
|
|
616
638
|
*/
|
|
617
639
|
function mergeHookWrites(
|
|
618
640
|
target: Record<string, unknown>,
|
|
@@ -621,39 +643,33 @@ function mergeHookWrites(
|
|
|
621
643
|
ancestors: Set<object>,
|
|
622
644
|
): void {
|
|
623
645
|
ancestors.add(after);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
646
|
+
mergeKeys(target, before, after, (key, b, a) => {
|
|
647
|
+
if (
|
|
648
|
+
!hasOwn(target, key) ||
|
|
649
|
+
!mergesWith(a, target[key], true) ||
|
|
650
|
+
// Stop at cycles: deepEqual() and deepClone() handle them
|
|
651
|
+
ancestors.has(a)
|
|
652
|
+
) {
|
|
653
|
+
return false;
|
|
630
654
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
655
|
+
mergeHookWrites(target[key] as Record<string, unknown>, b, a, ancestors);
|
|
656
|
+
return true;
|
|
657
|
+
});
|
|
658
|
+
ancestors.delete(after);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Restore the PRNG from a snapshot, or reset it without one. */
|
|
662
|
+
function restorePRNGFrom(prng: PRNGSnapshot | null | undefined): void {
|
|
663
|
+
if (prng) {
|
|
664
|
+
restorePRNG(prng.seed, prng.pull);
|
|
635
665
|
} else {
|
|
636
|
-
|
|
637
|
-
if (!hasOwn(after, key)) delete target[key];
|
|
638
|
-
}
|
|
639
|
-
for (const key of Object.keys(after)) {
|
|
640
|
-
if (hasOwn(before, key)) {
|
|
641
|
-
mergeHookWrite(target, key, before[key], after[key], ancestors);
|
|
642
|
-
} else {
|
|
643
|
-
target[key] = deepClone(after[key]);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
666
|
+
resetPRNG();
|
|
646
667
|
}
|
|
647
|
-
ancestors.delete(after);
|
|
648
668
|
}
|
|
649
669
|
|
|
650
670
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
651
671
|
function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
|
|
652
|
-
if (moment
|
|
653
|
-
restorePRNG(moment.prng.seed, moment.prng.pull);
|
|
654
|
-
} else if (moment) {
|
|
655
|
-
resetPRNG();
|
|
656
|
-
}
|
|
672
|
+
if (moment) restorePRNGFrom(moment.prng);
|
|
657
673
|
}
|
|
658
674
|
|
|
659
675
|
export interface HistoryMoment {
|
|
@@ -879,6 +895,62 @@ function handled<T>(p: Promise<T>): Promise<T> {
|
|
|
879
895
|
return p;
|
|
880
896
|
}
|
|
881
897
|
|
|
898
|
+
/**
|
|
899
|
+
* `p` with its failure logged under `message` and passed to `onError`, and
|
|
900
|
+
* still rejecting; marked as handled (see handled()).
|
|
901
|
+
*/
|
|
902
|
+
function reported<T>(
|
|
903
|
+
p: Promise<T>,
|
|
904
|
+
message: string,
|
|
905
|
+
onError?: (err: unknown) => void,
|
|
906
|
+
): Promise<T> {
|
|
907
|
+
return handled(
|
|
908
|
+
p.catch((err: unknown) => {
|
|
909
|
+
console.error(message, err);
|
|
910
|
+
onError?.(err);
|
|
911
|
+
throw err;
|
|
912
|
+
}),
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Record in the slot cache whether `slot` holds a save. */
|
|
917
|
+
function recordKnownSave(
|
|
918
|
+
set: StoreSet,
|
|
919
|
+
slot: string | undefined,
|
|
920
|
+
known: boolean,
|
|
921
|
+
): void {
|
|
922
|
+
set((state) => {
|
|
923
|
+
const key = slot ?? '';
|
|
924
|
+
if (known) {
|
|
925
|
+
state.knownSaves = { ...state.knownSaves, [key]: true };
|
|
926
|
+
} else {
|
|
927
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
928
|
+
state.knownSaves = rest as Record<string, true>;
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Queue the clearing of saved data, then restart now: the new playthrough
|
|
935
|
+
* is stored after it, and operations issued from here on belong to the new
|
|
936
|
+
* game. The slot cache empties once the clearing is done, after operations
|
|
937
|
+
* issued before it have updated it.
|
|
938
|
+
*/
|
|
939
|
+
function clearAndRestart(
|
|
940
|
+
get: StoreGet,
|
|
941
|
+
set: StoreSet,
|
|
942
|
+
clearing: Promise<void>,
|
|
943
|
+
message: string,
|
|
944
|
+
): Promise<void> {
|
|
945
|
+
const cleared = clearing.then(() => {
|
|
946
|
+
set((state) => {
|
|
947
|
+
state.knownSaves = {};
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
get().restart();
|
|
951
|
+
return reported(cleared, message);
|
|
952
|
+
}
|
|
953
|
+
|
|
882
954
|
export const useStoryStore = create<StoryState>()(
|
|
883
955
|
storyStore((set, get) => ({
|
|
884
956
|
storyData: null,
|
|
@@ -939,27 +1011,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
939
1011
|
);
|
|
940
1012
|
}
|
|
941
1013
|
|
|
942
|
-
const initialVars =
|
|
943
|
-
resetModuleState(deepClone(initialVars));
|
|
1014
|
+
const initialVars = startVariables(variableDefaults);
|
|
944
1015
|
|
|
945
1016
|
set((state) => {
|
|
946
1017
|
state.storyData = storyData as StoryData;
|
|
947
1018
|
// Unknown until the save system has looked it up (below)
|
|
948
1019
|
state.playthroughId = '';
|
|
949
|
-
state.currentPassage = startPassage.name;
|
|
950
|
-
state.navigationId++;
|
|
951
|
-
state.variables = initialVars;
|
|
952
1020
|
state.variableDefaults = variableDefaults;
|
|
953
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
954
1021
|
state.transientDefaults = transientDefaults;
|
|
955
|
-
state.
|
|
956
|
-
state.history = [
|
|
957
|
-
{
|
|
958
|
-
passage: startPassage.name,
|
|
959
|
-
timestamp: Date.now(),
|
|
960
|
-
},
|
|
961
|
-
];
|
|
962
|
-
state.historyIndex = 0;
|
|
1022
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
963
1023
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
964
1024
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
965
1025
|
});
|
|
@@ -1072,65 +1132,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1072
1132
|
for (const next of deferred) get().navigate(next);
|
|
1073
1133
|
},
|
|
1074
1134
|
|
|
1075
|
-
goBack: () =>
|
|
1076
|
-
const { historyIndex } = get();
|
|
1077
|
-
if (historyIndex <= 0) return;
|
|
1078
|
-
finishEnteredMoment(get, set);
|
|
1079
|
-
|
|
1080
|
-
const previousPassage = get().currentPassage;
|
|
1081
|
-
const targetPassage = get().history[historyIndex - 1]!.passage;
|
|
1082
|
-
emit('beforenavigate', targetPassage);
|
|
1083
|
-
|
|
1084
|
-
// Restore the recorded snapshot; live variables may hold edits made
|
|
1085
|
-
// since the current moment was recorded.
|
|
1086
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
1087
|
-
|
|
1088
|
-
set((state) => {
|
|
1089
|
-
state.historyIndex--;
|
|
1090
|
-
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
1091
|
-
state.navigationId++;
|
|
1092
|
-
state.variables = restoredVars;
|
|
1093
|
-
state.temporary = createNamespace();
|
|
1094
|
-
});
|
|
1095
|
-
|
|
1096
|
-
// Restored state is not a change watchers react to
|
|
1097
|
-
reinitTriggerState();
|
|
1098
|
-
lastNavigationVars = get().variables;
|
|
1099
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
1100
|
-
persistSession(get);
|
|
1101
|
-
|
|
1102
|
-
emit('afternavigate', targetPassage, previousPassage);
|
|
1103
|
-
},
|
|
1104
|
-
|
|
1105
|
-
goForward: () => {
|
|
1106
|
-
const { historyIndex, history: hist } = get();
|
|
1107
|
-
if (historyIndex >= hist.length - 1) return;
|
|
1108
|
-
finishEnteredMoment(get, set);
|
|
1109
|
-
|
|
1110
|
-
const previousPassage = get().currentPassage;
|
|
1111
|
-
const targetPassage = hist[historyIndex + 1]!.passage;
|
|
1112
|
-
emit('beforenavigate', targetPassage);
|
|
1113
|
-
|
|
1114
|
-
// Restore the recorded snapshot; live variables may hold edits made
|
|
1115
|
-
// since the current moment was recorded.
|
|
1116
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
1117
|
-
|
|
1118
|
-
set((state) => {
|
|
1119
|
-
state.historyIndex++;
|
|
1120
|
-
state.currentPassage = state.history[state.historyIndex]!.passage;
|
|
1121
|
-
state.navigationId++;
|
|
1122
|
-
state.variables = restoredVars;
|
|
1123
|
-
state.temporary = createNamespace();
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
// Restored state is not a change watchers react to
|
|
1127
|
-
reinitTriggerState();
|
|
1128
|
-
lastNavigationVars = get().variables;
|
|
1129
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
1130
|
-
persistSession(get);
|
|
1135
|
+
goBack: () => moveInHistory(get, set, -1),
|
|
1131
1136
|
|
|
1132
|
-
|
|
1133
|
-
},
|
|
1137
|
+
goForward: () => moveInHistory(get, set, 1),
|
|
1134
1138
|
|
|
1135
1139
|
setVariable: (name: string, value: unknown) => {
|
|
1136
1140
|
set((state) => {
|
|
@@ -1215,22 +1219,10 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1215
1219
|
|
|
1216
1220
|
resetPRNG();
|
|
1217
1221
|
resetTriggers();
|
|
1218
|
-
const initialVars =
|
|
1219
|
-
resetModuleState(deepClone(initialVars));
|
|
1222
|
+
const initialVars = startVariables(variableDefaults);
|
|
1220
1223
|
|
|
1221
1224
|
set((state) => {
|
|
1222
|
-
state
|
|
1223
|
-
state.navigationId++;
|
|
1224
|
-
state.variables = initialVars;
|
|
1225
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
1226
|
-
state.temporary = createNamespace();
|
|
1227
|
-
state.history = [
|
|
1228
|
-
{
|
|
1229
|
-
passage: startPassage.name,
|
|
1230
|
-
timestamp: Date.now(),
|
|
1231
|
-
},
|
|
1232
|
-
];
|
|
1233
|
-
state.historyIndex = 0;
|
|
1225
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
1234
1226
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
1235
1227
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
1236
1228
|
if (!keepDeferred) {
|
|
@@ -1256,26 +1248,20 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1256
1248
|
// The playthrough current now, not when the write runs
|
|
1257
1249
|
const playthrough = resolvePlaythroughId();
|
|
1258
1250
|
|
|
1259
|
-
return
|
|
1251
|
+
return reported(
|
|
1260
1252
|
saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
1261
1253
|
set((state) => {
|
|
1262
1254
|
state.saveError = null;
|
|
1263
1255
|
});
|
|
1264
1256
|
// Queued now, in call order with other storage operations
|
|
1265
1257
|
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
1266
|
-
set
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
};
|
|
1271
|
-
});
|
|
1272
|
-
}).catch((err) => {
|
|
1273
|
-
console.error('spindle: failed to save', err);
|
|
1258
|
+
recordKnownSave(set, slot, true);
|
|
1259
|
+
}),
|
|
1260
|
+
'spindle: failed to save',
|
|
1261
|
+
(err) =>
|
|
1274
1262
|
set((state) => {
|
|
1275
1263
|
state.saveError = errorMessage(err, 'Failed to save');
|
|
1276
|
-
})
|
|
1277
|
-
throw err;
|
|
1278
|
-
}),
|
|
1264
|
+
}),
|
|
1279
1265
|
);
|
|
1280
1266
|
},
|
|
1281
1267
|
|
|
@@ -1299,24 +1285,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1299
1285
|
),
|
|
1300
1286
|
);
|
|
1301
1287
|
const replacement = ++stateReplacementsIssued;
|
|
1302
|
-
return
|
|
1303
|
-
read
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
state.loadError = errorMessage(err, 'Failed to load');
|
|
1318
|
-
});
|
|
1319
|
-
throw err;
|
|
1288
|
+
return reported(
|
|
1289
|
+
read.then(async (loaded) => {
|
|
1290
|
+
// The store names the loaded playthrough before the loaded state
|
|
1291
|
+
// is applied (and `afterload` fires)
|
|
1292
|
+
await switched;
|
|
1293
|
+
if (!loaded) return;
|
|
1294
|
+
// A restart, boot or direct load issued after this one won
|
|
1295
|
+
if (latestStateApplied > replacement) return;
|
|
1296
|
+
slotLoadApplying = replacement;
|
|
1297
|
+
get().loadFromPayload(loaded.payload, slot);
|
|
1298
|
+
}),
|
|
1299
|
+
'spindle: failed to load save',
|
|
1300
|
+
(err) =>
|
|
1301
|
+
set((state) => {
|
|
1302
|
+
state.loadError = errorMessage(err, 'Failed to load');
|
|
1320
1303
|
}),
|
|
1321
1304
|
);
|
|
1322
1305
|
},
|
|
@@ -1325,7 +1308,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1325
1308
|
const { storyData, knownSaves } = get();
|
|
1326
1309
|
if (!storyData) return false;
|
|
1327
1310
|
// Own entries only: slot names like 'constructor' are not inherited saves
|
|
1328
|
-
return
|
|
1311
|
+
return hasOwn(knownSaves, slot ?? '');
|
|
1329
1312
|
},
|
|
1330
1313
|
|
|
1331
1314
|
getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
|
|
@@ -1344,19 +1327,11 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1344
1327
|
const { storyData } = get();
|
|
1345
1328
|
if (!storyData) return Promise.resolve();
|
|
1346
1329
|
|
|
1347
|
-
return
|
|
1348
|
-
deleteSlotSave(storyData.ifid, slot)
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
const { [key]: _, ...rest } = state.knownSaves;
|
|
1353
|
-
state.knownSaves = rest as Record<string, true>;
|
|
1354
|
-
});
|
|
1355
|
-
})
|
|
1356
|
-
.catch((err) => {
|
|
1357
|
-
console.error('spindle: failed to delete save', err);
|
|
1358
|
-
throw err;
|
|
1359
|
-
}),
|
|
1330
|
+
return reported(
|
|
1331
|
+
deleteSlotSave(storyData.ifid, slot).then(() =>
|
|
1332
|
+
recordKnownSave(set, slot, false),
|
|
1333
|
+
),
|
|
1334
|
+
'spindle: failed to delete save',
|
|
1360
1335
|
);
|
|
1361
1336
|
},
|
|
1362
1337
|
|
|
@@ -1372,9 +1347,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1372
1347
|
if (!isSaveExport(data)) throw new Error('Invalid save file format');
|
|
1373
1348
|
|
|
1374
1349
|
const info = await importSlotSave(data, storyData.ifid, slot);
|
|
1375
|
-
set
|
|
1376
|
-
state.knownSaves = { ...state.knownSaves, [slot ?? '']: true };
|
|
1377
|
-
});
|
|
1350
|
+
recordKnownSave(set, slot, true);
|
|
1378
1351
|
return info;
|
|
1379
1352
|
},
|
|
1380
1353
|
|
|
@@ -1382,39 +1355,21 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1382
1355
|
const { storyData } = get();
|
|
1383
1356
|
if (!storyData) return Promise.resolve();
|
|
1384
1357
|
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
set((state) => {
|
|
1391
|
-
state.knownSaves = {};
|
|
1392
|
-
});
|
|
1393
|
-
});
|
|
1394
|
-
get().restart();
|
|
1395
|
-
return handled(
|
|
1396
|
-
cleared.catch((err) => {
|
|
1397
|
-
console.error('spindle: failed to clear game data', err);
|
|
1398
|
-
throw err;
|
|
1399
|
-
}),
|
|
1358
|
+
return clearAndRestart(
|
|
1359
|
+
get,
|
|
1360
|
+
set,
|
|
1361
|
+
smClearGameData(storyData.ifid),
|
|
1362
|
+
'spindle: failed to clear game data',
|
|
1400
1363
|
);
|
|
1401
1364
|
},
|
|
1402
1365
|
|
|
1403
|
-
clearAllData: () =>
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
set
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
get().restart();
|
|
1411
|
-
return handled(
|
|
1412
|
-
cleared.catch((err) => {
|
|
1413
|
-
console.error('spindle: failed to clear all data', err);
|
|
1414
|
-
throw err;
|
|
1415
|
-
}),
|
|
1416
|
-
);
|
|
1417
|
-
},
|
|
1366
|
+
clearAllData: () =>
|
|
1367
|
+
clearAndRestart(
|
|
1368
|
+
get,
|
|
1369
|
+
set,
|
|
1370
|
+
smClearAllData(),
|
|
1371
|
+
'spindle: failed to clear all data',
|
|
1372
|
+
),
|
|
1418
1373
|
|
|
1419
1374
|
deletePlaythrough: (playthroughId: string) => {
|
|
1420
1375
|
const { storyData } = get();
|
|
@@ -1444,18 +1399,14 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1444
1399
|
);
|
|
1445
1400
|
}
|
|
1446
1401
|
|
|
1447
|
-
return
|
|
1448
|
-
deletion
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
.catch((err) => {
|
|
1456
|
-
console.error('spindle: failed to delete playthrough', err);
|
|
1457
|
-
throw err;
|
|
1458
|
-
}),
|
|
1402
|
+
return reported(
|
|
1403
|
+
deletion.then(async () => {
|
|
1404
|
+
const known = await populateKnownSaves(storyData.ifid);
|
|
1405
|
+
set((state) => {
|
|
1406
|
+
state.knownSaves = known;
|
|
1407
|
+
});
|
|
1408
|
+
}),
|
|
1409
|
+
'spindle: failed to delete playthrough',
|
|
1459
1410
|
);
|
|
1460
1411
|
},
|
|
1461
1412
|
|
|
@@ -1471,18 +1422,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1471
1422
|
|
|
1472
1423
|
// Reconstruct full variable snapshots from base + patches
|
|
1473
1424
|
const saveHistory: SaveHistoryMoment[] = [];
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
vars = applyPatches(vars, patchEntries[i - 1]!.forward);
|
|
1478
|
-
}
|
|
1479
|
-
saveHistory.push({
|
|
1480
|
-
passage: history[i]!.passage,
|
|
1481
|
-
variables: plainCopy(vars),
|
|
1482
|
-
timestamp: history[i]!.timestamp,
|
|
1483
|
-
prng: history[i]!.prng,
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1425
|
+
forEachRecorded(history.length, (vars, i) => {
|
|
1426
|
+
saveHistory.push(savedMoment(history[i]!, plainCopy(vars)));
|
|
1427
|
+
});
|
|
1486
1428
|
|
|
1487
1429
|
return {
|
|
1488
1430
|
passage: currentPassage,
|
|
@@ -1555,12 +1497,9 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1555
1497
|
patchEntries = newPatchEntries;
|
|
1556
1498
|
// Seed the session cache from the payload's own snapshots, so
|
|
1557
1499
|
// persistSession does not rebuild them from the live variables.
|
|
1558
|
-
serializedHistory = payload.history.map((m) =>
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
timestamp: m.timestamp,
|
|
1562
|
-
prng: m.prng,
|
|
1563
|
-
}));
|
|
1500
|
+
serializedHistory = payload.history.map((m) =>
|
|
1501
|
+
savedMoment(m, serialize(m.variables)),
|
|
1502
|
+
);
|
|
1564
1503
|
|
|
1565
1504
|
set((state) => {
|
|
1566
1505
|
state.currentPassage = payload.passage;
|
|
@@ -1594,12 +1533,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1594
1533
|
|
|
1595
1534
|
// Replay the passage's random rolls from its entry PRNG state; saves
|
|
1596
1535
|
// whose moments predate PRNG snapshots use the payload's.
|
|
1597
|
-
|
|
1598
|
-
if (prng) {
|
|
1599
|
-
restorePRNG(prng.seed, prng.pull);
|
|
1600
|
-
} else {
|
|
1601
|
-
resetPRNG();
|
|
1602
|
-
}
|
|
1536
|
+
restorePRNGFrom(entry?.prng !== undefined ? entry.prng : payload.prng);
|
|
1603
1537
|
|
|
1604
1538
|
// Write the loaded game to the session so a refresh restores it
|
|
1605
1539
|
persistSession(get);
|