@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/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 { deepClone, deepEqual, serialize } from './class-registry';
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 (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
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
- let vars = variableBase;
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
- variableBase = variables;
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 arrays
611
- * (see mergeable()), to the snapshot's `target`. Array elements merge by
612
- * index. Elements the hooks removed from an array's end are removed from
613
- * the snapshot's array at the same indices, and elements they added are
614
- * appended to it: where the passage resized the array, its indices do not
615
- * line up with the snapshot's, and the passage resizes it again on load.
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
- if (Array.isArray(target)) {
625
- const b = before as unknown as unknown[];
626
- const a = after as unknown as unknown[];
627
- const shared = Math.min(b.length, a.length, target.length);
628
- for (let i = 0; i < shared; i++) {
629
- mergeHookWrite(target, String(i), b[i], a[i], ancestors);
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
- if (a.length < b.length) {
632
- target.length = Math.min(target.length, a.length);
633
- }
634
- for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
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
- for (const key of Object.keys(before)) {
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?.prng) {
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 = createNamespace(deepClone(variableDefaults));
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.temporary = createNamespace();
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
- emit('afternavigate', targetPassage, previousPassage);
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 = createNamespace(deepClone(variableDefaults));
1219
- resetModuleState(deepClone(initialVars));
1222
+ const initialVars = startVariables(variableDefaults);
1220
1223
 
1221
1224
  set((state) => {
1222
- state.currentPassage = startPassage.name;
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 handled(
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((state) => {
1267
- state.knownSaves = {
1268
- ...state.knownSaves,
1269
- [slot ?? '']: true,
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 handled(
1303
- read
1304
- .then(async (loaded) => {
1305
- // The store names the loaded playthrough before the loaded
1306
- // state is applied (and `afterload` fires)
1307
- await switched;
1308
- if (!loaded) return;
1309
- // A restart, boot or direct load issued after this one won
1310
- if (latestStateApplied > replacement) return;
1311
- slotLoadApplying = replacement;
1312
- get().loadFromPayload(loaded.payload, slot);
1313
- })
1314
- .catch((err) => {
1315
- console.error('spindle: failed to load save', err);
1316
- set((state) => {
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 Object.prototype.hasOwnProperty.call(knownSaves, slot ?? '');
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 handled(
1348
- deleteSlotSave(storyData.ifid, slot)
1349
- .then(() => {
1350
- set((state) => {
1351
- const key = slot ?? '';
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((state) => {
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
- // Queue the clearing, then restart now: the new playthrough is stored
1386
- // after it, and operations issued from here on belong to the new game.
1387
- // The slot cache empties once the clearing is done, after operations
1388
- // issued before it have updated it.
1389
- const cleared = smClearGameData(storyData.ifid).then(() => {
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
- // As clearGameData: queue the clearing, then restart now
1405
- const cleared = smClearAllData().then(() => {
1406
- set((state) => {
1407
- state.knownSaves = {};
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 handled(
1448
- deletion
1449
- .then(async () => {
1450
- const known = await populateKnownSaves(storyData.ifid);
1451
- set((state) => {
1452
- state.knownSaves = known;
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
- let vars = variableBase;
1475
- for (let i = 0; i < history.length; i++) {
1476
- if (i > 0) {
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
- passage: m.passage,
1560
- variables: serialize(m.variables),
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
- const prng = entry?.prng !== undefined ? entry.prng : payload.prng;
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);