@rohal12/spindle 0.51.4 → 0.52.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.
Files changed (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. package/types/tooling.d.ts +1 -0
package/src/store.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { create } from './preact-store';
2
2
  import { immer } from 'zustand/middleware/immer';
3
+ import type { StateCreator } from 'zustand/vanilla';
3
4
  import {
5
+ current,
6
+ enableMapSet,
4
7
  enablePatches,
8
+ isDraft,
5
9
  produceWithPatches,
6
10
  applyPatches,
11
+ type Draft,
7
12
  type Patch,
8
13
  } from 'immer';
9
14
  import type { StoryData } from './parser';
@@ -23,12 +28,12 @@ import {
23
28
  reinitTriggerState,
24
29
  } from './triggers';
25
30
  import {
26
- initSaveSystem,
31
+ establishPlaythrough,
27
32
  startNewPlaythrough,
28
- getCurrentPlaythroughId,
29
33
  quickSave,
30
34
  saveWithHooks,
31
- loadQuickSave,
35
+ loadSlotSave,
36
+ adoptPlaythrough,
32
37
  populateKnownSaves,
33
38
  getSlotSaveInfo,
34
39
  listSlotSaves,
@@ -41,15 +46,32 @@ import {
41
46
  clearAllData as smClearAllData,
42
47
  deletePlaythroughData as smDeletePlaythroughData,
43
48
  } from './saves/save-manager';
44
- import { deepClone, serialize } from './class-registry';
49
+ import { deepClone, deepEqual, serialize } from './class-registry';
45
50
  import {
46
51
  snapshotPRNG,
47
52
  restorePRNG,
48
53
  resetPRNG,
54
+ withoutDraws,
49
55
  type PRNGSnapshot,
50
56
  } from './prng';
57
+ import { errorMessage } from './utils/error-message';
58
+ import {
59
+ isMergeable,
60
+ routeStoreUpdate,
61
+ runWithCommittedMutations,
62
+ } from './execute-mutation';
63
+ import {
64
+ checkVariableName,
65
+ createNamespace,
66
+ isNamespace,
67
+ type Namespace,
68
+ } from './utils/namespace';
69
+ import { createCounts, type Counts } from './utils/counts';
51
70
 
52
71
  enablePatches();
72
+ // Story state holds Map and Set values: Immer must be able to draft them
73
+ // when a write (a dot path, a macro binding) reaches one
74
+ enableMapSet();
53
75
 
54
76
  const SPECIAL_PASSAGES = new Set([
55
77
  'StoryInit',
@@ -74,7 +96,7 @@ interface PatchEntry {
74
96
  }
75
97
 
76
98
  /** Full variable snapshot at history index 0. */
77
- let variableBase: Record<string, unknown> = {};
99
+ let variableBase: Namespace = createNamespace();
78
100
 
79
101
  /**
80
102
  * Transitions between consecutive history moments.
@@ -84,7 +106,7 @@ let variableBase: Record<string, unknown> = {};
84
106
  let patchEntries: PatchEntry[] = [];
85
107
 
86
108
  /** Immer-produced reference to variables right after the last navigation. */
87
- let lastNavigationVars: Record<string, unknown> = {};
109
+ let lastNavigationVars: Namespace = createNamespace();
88
110
 
89
111
  /** Deep-clone patch values so they are independent of future mutations. */
90
112
  function clonePatches(patches: Patch[]): Patch[] {
@@ -102,7 +124,9 @@ function computeVarPatches(
102
124
  const [, forward, inverse] = produceWithPatches(prev, (draft) => {
103
125
  const d = draft as Record<string, unknown>;
104
126
  for (const key of Object.keys(d)) {
105
- if (!(key in curr)) delete d[key];
127
+ // Own keys only: `curr` may be a plain object (a loaded snapshot),
128
+ // whose inherited `constructor` is no variable
129
+ if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
106
130
  }
107
131
  for (const [key, val] of Object.entries(curr)) {
108
132
  d[key] = val;
@@ -197,14 +221,88 @@ function persistSession(get: () => StoryState): void {
197
221
  });
198
222
  }
199
223
 
224
+ /**
225
+ * Trim history to `state.maxHistory` moments: keep the newest moments that
226
+ * include the current one. After a navigation (the current moment is the
227
+ * newest) that drops the oldest; when the player has gone back further than
228
+ * the limit allows, the moments after the newest kept one are dropped too.
229
+ * Call it inside a store update; the module-level variable history
230
+ * (base, patches, session cache) is trimmed alongside.
231
+ */
232
+ function trimHistory(state: {
233
+ history: HistoryMoment[];
234
+ historyIndex: number;
235
+ maxHistory: number;
236
+ }): boolean {
237
+ const excess = state.history.length - state.maxHistory;
238
+ if (excess <= 0) return false;
239
+ const start = Math.min(state.historyIndex, excess);
240
+ const end = start + state.maxHistory;
241
+ // Advance base through trimmed transitions
242
+ for (let i = 0; i < start; i++) {
243
+ variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
244
+ }
245
+ state.history = state.history.slice(start, end);
246
+ patchEntries = patchEntries.slice(start, end - 1);
247
+ serializedHistory = serializedHistory.slice(start, end);
248
+ state.historyIndex -= start;
249
+ return true;
250
+ }
251
+
200
252
  /** True while navigate() lets watchers react to the moment it entered. */
201
253
  let navigationTriggerPhase = false;
202
254
 
203
255
  /** Navigations requested during the trigger phase, run once it is over. */
204
256
  let deferredNavigations: string[] = [];
205
257
 
258
+ /**
259
+ * The moment navigate() entered, while its watchers may still change it:
260
+ * the navigation it belongs to and the variables it was recorded with.
261
+ */
262
+ let enteredMoment: {
263
+ navigationId: number;
264
+ variables: Record<string, unknown>;
265
+ } | null = null;
266
+
267
+ /**
268
+ * Record the entered moment as it is now (watcher run actions and the PRNG
269
+ * rolls they made belong to it), unless the story has left it already.
270
+ * navigate() calls this after its watchers, and back/forward before they
271
+ * leave the moment: a watcher that moves through history must not have the
272
+ * moment it leaves recorded with the state of the one it arrives at.
273
+ */
274
+ function finishEnteredMoment(
275
+ get: () => StoryState,
276
+ set: (recipe: (state: StoryState) => void) => void,
277
+ ): void {
278
+ const moment = enteredMoment;
279
+ enteredMoment = null;
280
+ if (!moment || get().navigationId !== moment.navigationId) return;
281
+ if (get().variables !== moment.variables) {
282
+ rerecordNewestMoment(get().variables);
283
+ }
284
+ // The next navigate() diffs from this recorded snapshot. A watcher that
285
+ // left the moment has set it to the snapshot of the one it went to.
286
+ lastNavigationVars = get().variables;
287
+ const prng = snapshotPRNG();
288
+ const recorded = get().history[get().historyIndex]!.prng;
289
+ if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
290
+ set((state) => {
291
+ state.history[state.historyIndex]!.prng = prng;
292
+ });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * A deep copy of a variable namespace as save data: a plain object, as a
298
+ * loaded save holds it (the store turns it back into a namespace on load).
299
+ */
300
+ const plainCopy = (ns: Namespace): Record<string, unknown> => ({
301
+ ...deepClone(ns),
302
+ });
303
+
206
304
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
207
- function resetModuleState(base: Record<string, unknown>): void {
305
+ function resetModuleState(base: Namespace): void {
208
306
  variableBase = base;
209
307
  patchEntries = [];
210
308
  lastNavigationVars = base;
@@ -239,29 +337,139 @@ export function recordStoryInitState(): void {
239
337
  // ---------------------------------------------------------------------------
240
338
 
241
339
  /**
242
- * Settles once the latest playthrough setup (init's lookup or creation, or a
243
- * restart's creation) is stored, with that setup's playthrough ID ('' if
244
- * init could not establish one). Each setup chains on the previous one, so
245
- * playthroughs are created and numbered in the order the game started them.
340
+ * Settles once the latest playthrough switch (init's lookup or creation, a
341
+ * restart's creation, the replacement of a deleted current playthrough, a
342
+ * load making the loaded save's playthrough current) is stored, with the
343
+ * playthrough ID it leaves the game in ('' if init could not establish one).
344
+ * Switches are storage operations, which run in call order, so playthroughs
345
+ * are created and numbered in the order the game started them, and a save
346
+ * issued after a switch is stored after it, in the playthrough it switched
347
+ * to.
246
348
  */
247
349
  let playthroughSetup: Promise<string> = Promise.resolve('');
248
350
 
249
- /** Bumped by every init()/restart(); a stale init must not adopt its ID. */
351
+ /**
352
+ * Bumped by every playthrough switch; a switch that settles after a later
353
+ * one was issued must not set its ID.
354
+ */
250
355
  let playthroughGeneration = 0;
251
356
 
357
+ /**
358
+ * Whether the latest switch is to a playthrough not known until a storage
359
+ * operation has run: the one init looks up, the playthrough of the save a
360
+ * load from a slot reads, or (while one of those is pending) the one a
361
+ * playthrough deletion leaves the game in. Meanwhile the store's
362
+ * `playthroughId` is the one before ('' at boot), and saves issued take the
363
+ * one the switch establishes.
364
+ */
365
+ let playthroughPending = false;
366
+
367
+ /** The game's playthrough now, or '' while a pending switch decides it. */
368
+ function knownPlaythroughId(): string {
369
+ return playthroughPending ? '' : useStoryStore.getState().playthroughId;
370
+ }
371
+
252
372
  /**
253
373
  * The playthrough a save issued now belongs to, once its record is stored.
254
374
  * Read synchronously at the call: restart() switches the store's
255
375
  * `playthroughId` at once, so a save issued after it (even before the new
256
376
  * playthrough is stored) belongs to the new playthrough, and a later restart
257
- * doesn't move it. Before init() has looked up the stored playthrough the
258
- * store's ID is '', and the save takes the one init establishes.
377
+ * doesn't move it. While a switch whose playthrough is not known yet is
378
+ * pending (init's lookup, a load from a slot), the save takes the one that
379
+ * switch establishes.
259
380
  */
260
381
  export function resolvePlaythroughId(): Promise<string> {
261
- const current = useStoryStore.getState().playthroughId;
382
+ const current = knownPlaythroughId();
262
383
  return playthroughSetup.then((established) => current || established);
263
384
  }
264
385
 
386
+ function setPlaythroughId(id: string): void {
387
+ if (useStoryStore.getState().playthroughId === id) return;
388
+ useStoryStore.setState((state) => {
389
+ state.playthroughId = id;
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Switch to the playthrough `lookup` (a storage operation queued now)
395
+ * resolves to. Saves issued meanwhile belong to it; the store's
396
+ * `playthroughId` is set once it is known, unless a later switch was issued.
397
+ */
398
+ function switchToLookedUpPlaythrough(lookup: Promise<string>): Promise<string> {
399
+ const generation = ++playthroughGeneration;
400
+ playthroughPending = true;
401
+ playthroughSetup = lookup.then((id) => {
402
+ if (generation === playthroughGeneration) {
403
+ playthroughPending = false;
404
+ setPlaythroughId(id);
405
+ }
406
+ return id;
407
+ });
408
+ return playthroughSetup;
409
+ }
410
+
411
+ /**
412
+ * Move the running game to the playthrough `id` at once: saves issued from
413
+ * here on belong to it. `stored` is the storage operation recording the
414
+ * switch, queued now, after those already issued (its failure is reported
415
+ * by the caller).
416
+ */
417
+ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
418
+ ++playthroughGeneration;
419
+ playthroughPending = false;
420
+ playthroughSetup = stored.then(
421
+ () => id,
422
+ () => id,
423
+ );
424
+ setPlaythroughId(id);
425
+ }
426
+
427
+ /** Move the running game to a new playthrough at once (see restart). */
428
+ function switchToNewPlaythrough(ifid: string): void {
429
+ const id = crypto.randomUUID();
430
+ const stored = startNewPlaythrough(ifid, id).catch((err) => {
431
+ console.error('spindle: failed to start new playthrough', err);
432
+ });
433
+ switchToPlaythrough(id, stored);
434
+ }
435
+
436
+ /**
437
+ * Move the running game to the playthrough of a save it loads, at once. A
438
+ * no-op if the game is in it already.
439
+ */
440
+ function switchToLoadedPlaythrough(ifid: string, id: string): void {
441
+ if (knownPlaythroughId() === id) return;
442
+ const stored = adoptPlaythrough(ifid, id).catch((err) => {
443
+ console.error('spindle: failed to switch to the loaded playthrough', err);
444
+ });
445
+ switchToPlaythrough(id, stored);
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Superseded loads
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * A load from a slot reads the save in the order of storage operations and
454
+ * applies it when the read completes. A restart, a boot or a direct load
455
+ * (loadFromPayload) issued after it replaces the game state at once; the
456
+ * slot load, completing later, must not undo that. Every replacement of the
457
+ * game state takes a number in call order, and a slot load applies only if
458
+ * no replacement issued after it has been applied. Loads from slots apply in
459
+ * call order anyway (their reads are queued), so they never supersede one
460
+ * another.
461
+ */
462
+ let stateReplacementsIssued = 0;
463
+ let latestStateApplied = 0;
464
+
465
+ /** The number of the slot load that is calling loadFromPayload. */
466
+ let slotLoadApplying: number | null = null;
467
+
468
+ /** A replacement of the game state applied at its call. */
469
+ function replaceStateNow(): void {
470
+ latestStateApplied = ++stateReplacementsIssued;
471
+ }
472
+
265
473
  // ---------------------------------------------------------------------------
266
474
  // Runtime handler cleanup (auto-unsub on restart)
267
475
  // ---------------------------------------------------------------------------
@@ -326,13 +534,14 @@ function loadedEntryMoment(
326
534
  }
327
535
 
328
536
  /**
329
- * Copy the variables that changed between `before` and `after` (the live
330
- * variables around the `beforesave` hooks) into the payload's snapshot of the
331
- * saved moment. A load restores that snapshot, the state on entering the
332
- * passage, and runs the passage again, so data a hook adds to a save would
333
- * otherwise be lost on load (#227). Only the payload's copy changes: the live
334
- * history keeps the recorded snapshot (#159). Store updates are immutable, so
335
- * a value a hook did not touch keeps its identity.
537
+ * Write the changes between `before` and `after` (the live variables around
538
+ * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
539
+ * load restores that snapshot, the state on entering the passage, and runs
540
+ * the passage again, so data a hook adds to a save would otherwise be lost
541
+ * on load (#227). Only the property paths the hooks changed are written: a
542
+ * whole variable would bring along what the passage did to the rest of it,
543
+ * which the passage then does again on load (#232). Only the payload's copy
544
+ * changes: the live history keeps the recorded snapshot (#159).
336
545
  */
337
546
  function keepHookWrites(
338
547
  payload: SavePayload,
@@ -341,15 +550,100 @@ function keepHookWrites(
341
550
  ): void {
342
551
  const moment = payload.history[payload.historyIndex];
343
552
  if (!moment) return;
344
- const has = (vars: Record<string, unknown>, key: string) =>
345
- Object.prototype.hasOwnProperty.call(vars, key);
346
- for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
347
- if (!has(after, key)) {
348
- delete moment.variables[key];
349
- } else if (!has(before, key) || after[key] !== before[key]) {
350
- moment.variables[key] = deepClone(after[key]);
553
+ mergeHookWrites(moment.variables, before, after, new Set());
554
+ }
555
+
556
+ const hasOwn = (obj: object, key: string): boolean =>
557
+ Object.prototype.hasOwnProperty.call(obj, key);
558
+
559
+ /**
560
+ * Whether changes between `a` and `b` merge key by key: both are arrays,
561
+ * or both are objects of one class (see isMergeable).
562
+ */
563
+ function mergeable(a: unknown, b: unknown): boolean {
564
+ if (Array.isArray(a) || Array.isArray(b)) {
565
+ return Array.isArray(a) && Array.isArray(b);
566
+ }
567
+ return (
568
+ isMergeable(a) &&
569
+ isMergeable(b) &&
570
+ Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
571
+ );
572
+ }
573
+
574
+ /**
575
+ * Apply the hooks' change of a value, from `before` to `after`, to the
576
+ * snapshot's copy of it, `target[key]`. Store updates are immutable, so a
577
+ * value no hook touched keeps its identity; one rebuilt with equal content
578
+ * (mutation code commits whole values) is no change either. Where `target`
579
+ * lacks the key or holds another kind of value (the passage created or
580
+ * replaced it), the hooks' whole value is written.
581
+ */
582
+ function mergeHookWrite(
583
+ target: Record<string, unknown>,
584
+ key: string,
585
+ before: unknown,
586
+ after: unknown,
587
+ ancestors: Set<object>,
588
+ ): void {
589
+ if (Object.is(before, after)) return;
590
+ if (
591
+ mergeable(before, after) &&
592
+ hasOwn(target, key) &&
593
+ mergeable(after, target[key]) &&
594
+ // Stop at cycles: deepEqual() and deepClone() handle them
595
+ !ancestors.has(after as object)
596
+ ) {
597
+ mergeHookWrites(
598
+ target[key] as Record<string, unknown>,
599
+ before as Record<string, unknown>,
600
+ after as Record<string, unknown>,
601
+ ancestors,
602
+ );
603
+ } else if (!deepEqual(before, after)) {
604
+ target[key] = deepClone(after);
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Apply the changes between `before` and `after`, two objects or two arrays
610
+ * (see mergeable()), to the snapshot's `target`. Array elements merge by
611
+ * index. Elements the hooks removed from an array's end are removed from
612
+ * the snapshot's array at the same indices, and elements they added are
613
+ * appended to it: where the passage resized the array, its indices do not
614
+ * line up with the snapshot's, and the passage resizes it again on load.
615
+ */
616
+ function mergeHookWrites(
617
+ target: Record<string, unknown>,
618
+ before: Record<string, unknown>,
619
+ after: Record<string, unknown>,
620
+ ancestors: Set<object>,
621
+ ): void {
622
+ ancestors.add(after);
623
+ if (Array.isArray(target)) {
624
+ const b = before as unknown as unknown[];
625
+ const a = after as unknown as unknown[];
626
+ const shared = Math.min(b.length, a.length, target.length);
627
+ for (let i = 0; i < shared; i++) {
628
+ mergeHookWrite(target, String(i), b[i], a[i], ancestors);
629
+ }
630
+ if (a.length < b.length) {
631
+ target.length = Math.min(target.length, a.length);
632
+ }
633
+ for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
634
+ } else {
635
+ for (const key of Object.keys(before)) {
636
+ if (!hasOwn(after, key)) delete target[key];
637
+ }
638
+ for (const key of Object.keys(after)) {
639
+ if (hasOwn(before, key)) {
640
+ mergeHookWrite(target, key, before[key], after[key], ancestors);
641
+ } else {
642
+ target[key] = deepClone(after[key]);
643
+ }
351
644
  }
352
645
  }
646
+ ancestors.delete(after);
353
647
  }
354
648
 
355
649
  /** Restore or reset PRNG from a history moment's snapshot. */
@@ -389,9 +683,15 @@ export interface StoryState {
389
683
  temporary: Record<string, unknown>;
390
684
  history: HistoryMoment[];
391
685
  historyIndex: number;
392
- visitCounts: Record<string, number>;
393
- renderCounts: Record<string, number>;
686
+ visitCounts: Counts;
687
+ renderCounts: Counts;
394
688
  knownSaves: Record<string, true>;
689
+ /**
690
+ * The playthrough the running game is in: its saves are grouped under it.
691
+ * Set by boot (the stored current playthrough), restart (a new one),
692
+ * loading a save (the save's) and deleting the current playthrough (a new
693
+ * one). '' until boot has looked it up.
694
+ */
395
695
  playthroughId: string;
396
696
  maxHistory: number;
397
697
  quickSaveKey: string | null;
@@ -428,6 +728,12 @@ export interface StoryState {
428
728
  trackRender: (passageName: string) => void;
429
729
  restart: () => void;
430
730
  save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
731
+ /**
732
+ * Load the save in a slot and move the game to its playthrough. The switch
733
+ * takes effect in call order (a save issued after the load belongs to the
734
+ * loaded playthrough); the state is applied when the save has been read,
735
+ * unless a restart or another direct load was issued after this load.
736
+ */
431
737
  load: (slot?: string) => Promise<void>;
432
738
  hasSave: (slot?: string) => boolean;
433
739
  getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
@@ -435,9 +741,18 @@ export interface StoryState {
435
741
  deleteSave: (slot?: string) => Promise<void>;
436
742
  exportSave: (slot?: string) => Promise<SaveExport | null>;
437
743
  importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
438
- clearGameData: () => void;
439
- clearAllData: () => void;
440
- deletePlaythrough: (playthroughId: string) => void;
744
+ /**
745
+ * Delete the story's saves and playthroughs and restart. The restart is
746
+ * immediate; the promise settles once the data is deleted.
747
+ */
748
+ clearGameData: () => Promise<void>;
749
+ /** As clearGameData, for all Spindle data (every story). */
750
+ clearAllData: () => Promise<void>;
751
+ /**
752
+ * Delete a playthrough and its saves. Deleting the current one moves the
753
+ * running game to a new playthrough.
754
+ */
755
+ deletePlaythrough: (playthroughId: string) => Promise<void>;
441
756
  getSavePayload: () => SavePayload;
442
757
  /**
443
758
  * Start capturing a save, before the `beforesave` hooks run. The returned
@@ -447,9 +762,15 @@ export interface StoryState {
447
762
  beginSave: () => () => SavePayload;
448
763
  /**
449
764
  * Replace the game state with a live (deserialized) payload. `slot` is
450
- * passed to the `beforeload`/`afterload` events.
765
+ * passed to the `beforeload`/`afterload` events. Pass the save's
766
+ * `playthroughId` when loading a save: the game moves to that playthrough
767
+ * (no change if it is the current one). Restoring the session passes none.
451
768
  */
452
- loadFromPayload: (payload: SavePayload, slot?: string) => void;
769
+ loadFromPayload: (
770
+ payload: SavePayload,
771
+ slot?: string,
772
+ playthroughId?: string,
773
+ ) => void;
453
774
  getHistoryVariables: (index: number) => Record<string, unknown>;
454
775
  setTransition: (config: TransitionConfig | null) => void;
455
776
  setNextTransition: (config: TransitionConfig | null) => void;
@@ -458,6 +779,94 @@ export interface StoryState {
458
779
  clearDeferredRender: () => void;
459
780
  }
460
781
 
782
+ const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
783
+
784
+ type StoryRecipe = (draft: Draft<StoryState>) => void;
785
+
786
+ /** Replace a namespace an update left with a prototype by one without. */
787
+ function keepNamespacesBare(draft: Draft<StoryState>): void {
788
+ for (const key of NAMESPACE_KEYS) {
789
+ const ns = draft[key];
790
+ if (!isNamespace(ns)) {
791
+ draft[key] = createNamespace(isDraft(ns) ? current(ns) : ns);
792
+ }
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Actions that record, replace or save story state. Called while mutation
798
+ * code runs (by Story.goto, a {link} or {back} the code performs, a
799
+ * watcher), they act in program order: the code's writes so far are
800
+ * committed first, and the code goes on from the state they leave (see
801
+ * runWithCommittedMutations). Outside mutation code they just run.
802
+ *
803
+ * A load from a slot (`load`) takes its place among the save operations
804
+ * (and switches playthroughs) at the call, but applies the save when its
805
+ * read completes, after the code has run.
806
+ */
807
+ const PROGRAM_ORDER_ACTIONS = [
808
+ 'navigate',
809
+ 'goBack',
810
+ 'goForward',
811
+ 'restart',
812
+ 'save',
813
+ 'load',
814
+ 'getSavePayload',
815
+ 'loadFromPayload',
816
+ ] as const;
817
+
818
+ /**
819
+ * Store middleware (inside `immer`) that every update goes through: the
820
+ * store's own actions and outside `setState` calls alike.
821
+ *
822
+ * - The variable namespaces stay records without a prototype, whatever an
823
+ * update assigns (see utils/namespace.ts).
824
+ * - An update made while mutation code runs follows that code's pending
825
+ * writes, in program order, and reaches its working copies (see
826
+ * routeStoreUpdate in execute-mutation.ts).
827
+ * - The PROGRAM_ORDER_ACTIONS commit running mutation code first.
828
+ */
829
+ function storyStateGuard(
830
+ creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
831
+ ): StateCreator<StoryState, [['zustand/immer', never]], []> {
832
+ return (set, get, api) => {
833
+ const guarded = ((
834
+ updater: Partial<StoryState> | StoryRecipe,
835
+ replace?: boolean,
836
+ ) => {
837
+ if (replace) {
838
+ (set as (u: unknown, r: true) => void)(updater, true);
839
+ return;
840
+ }
841
+ const recipe: StoryRecipe =
842
+ typeof updater === 'function'
843
+ ? updater
844
+ : (draft) => {
845
+ Object.assign(draft, updater);
846
+ };
847
+ const routed = routeStoreUpdate(recipe);
848
+ set((draft) => {
849
+ (routed ?? recipe)(draft);
850
+ keepNamespacesBare(draft);
851
+ });
852
+ }) as typeof set;
853
+ api.setState = guarded;
854
+ const state = creator(guarded, get, api);
855
+ for (const name of PROGRAM_ORDER_ACTIONS) {
856
+ const action = state[name] as (...args: unknown[]) => unknown;
857
+ (state as unknown as Record<string, unknown>)[name] = (
858
+ ...args: unknown[]
859
+ ) => runWithCommittedMutations(() => action(...args));
860
+ }
861
+ return state;
862
+ };
863
+ }
864
+
865
+ /** The story store's middleware: Immer updates, guarded (see above). */
866
+ const storyStore = (
867
+ creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
868
+ ) => immer(storyStateGuard(creator));
869
+
461
870
  /**
462
871
  * Return `p` marked as handled: a caller that ignores the result of a
463
872
  * fire-and-forget operation (whose failure is already logged) gets no
@@ -470,19 +879,19 @@ function handled<T>(p: Promise<T>): Promise<T> {
470
879
  }
471
880
 
472
881
  export const useStoryStore = create<StoryState>()(
473
- immer((set, get) => ({
882
+ storyStore((set, get) => ({
474
883
  storyData: null,
475
884
  currentPassage: '',
476
885
  navigationId: 0,
477
- variables: {},
886
+ variables: createNamespace(),
478
887
  variableDefaults: {},
479
- transient: {},
888
+ transient: createNamespace(),
480
889
  transientDefaults: {},
481
- temporary: {},
890
+ temporary: createNamespace(),
482
891
  history: [],
483
892
  historyIndex: -1,
484
- visitCounts: {},
485
- renderCounts: {},
893
+ visitCounts: createCounts(),
894
+ renderCounts: createCounts(),
486
895
  knownSaves: {},
487
896
  playthroughId: '',
488
897
  maxHistory: 40,
@@ -496,9 +905,13 @@ export const useStoryStore = create<StoryState>()(
496
905
  renderDeferred: false,
497
906
 
498
907
  setMaxHistory: (limit: number) => {
908
+ let trimmed = false;
499
909
  set((state) => {
500
910
  state.maxHistory = Math.max(1, Math.round(limit));
911
+ // A lower limit takes effect at once
912
+ trimmed = trimHistory(state);
501
913
  });
914
+ if (trimmed) persistSession(get);
502
915
  },
503
916
 
504
917
  setQuickSaveKey: (key: string | null) => {
@@ -525,7 +938,7 @@ export const useStoryStore = create<StoryState>()(
525
938
  );
526
939
  }
527
940
 
528
- const initialVars = deepClone(variableDefaults);
941
+ const initialVars = createNamespace(deepClone(variableDefaults));
529
942
  resetModuleState(deepClone(initialVars));
530
943
 
531
944
  set((state) => {
@@ -536,9 +949,9 @@ export const useStoryStore = create<StoryState>()(
536
949
  state.navigationId++;
537
950
  state.variables = initialVars;
538
951
  state.variableDefaults = variableDefaults;
539
- state.transient = deepClone(transientDefaults);
952
+ state.transient = createNamespace(deepClone(transientDefaults));
540
953
  state.transientDefaults = transientDefaults;
541
- state.temporary = {};
954
+ state.temporary = createNamespace();
542
955
  state.history = [
543
956
  {
544
957
  passage: startPassage.name,
@@ -546,43 +959,36 @@ export const useStoryStore = create<StoryState>()(
546
959
  },
547
960
  ];
548
961
  state.historyIndex = 0;
549
- state.visitCounts = { [startPassage.name]: 1 };
550
- state.renderCounts = { [startPassage.name]: 1 };
962
+ state.visitCounts = createCounts(null, startPassage.name);
963
+ state.renderCounts = createCounts(null, startPassage.name);
551
964
  });
552
965
 
553
966
  // Update lastNavigationVars to the Immer-produced reference
554
967
  lastNavigationVars = get().variables;
555
968
 
556
- // Init save system in the background. Saves issued meanwhile wait for
557
- // it (see resolvePlaythroughId), so they are tagged with the
558
- // playthrough it establishes and recorded after the known saves.
559
- const ifid = storyData.ifid;
560
- const generation = ++playthroughGeneration;
561
- playthroughSetup = initSaveSystem()
562
- .then(async () => {
563
- const id =
564
- (await getCurrentPlaythroughId(ifid)) ??
565
- (await startNewPlaythrough(ifid));
566
- // A restart issued meanwhile has already switched playthroughs
567
- if (generation === playthroughGeneration) {
568
- set((state) => {
569
- state.playthroughId = id;
570
- });
571
- }
572
-
573
- // Populate knownSaves from IDB so hasSave() works after reload
574
- const saves = await populateKnownSaves(ifid);
575
- if (Object.keys(saves).length > 0) {
969
+ replaceStateNow();
970
+
971
+ // Look up the story's playthrough and saves in the background, as a
972
+ // storage operation queued now: saves issued meanwhile are stored
973
+ // after it, tagged with the playthrough it establishes (see
974
+ // resolvePlaythroughId). The current playthrough is stored, so a page
975
+ // refresh stays in the playthrough the game was in, also after a load
976
+ // switched to the loaded save's.
977
+ switchToLookedUpPlaythrough(
978
+ establishPlaythrough(storyData.ifid)
979
+ .then(({ id, knownSaves }) => {
980
+ // So hasSave() works after a reload. Operations issued later
981
+ // update the cache after this.
576
982
  set((state) => {
577
- state.knownSaves = saves;
983
+ state.knownSaves = knownSaves;
578
984
  });
579
- }
580
- return id;
581
- })
582
- .catch((err) => {
583
- console.error('spindle: failed to init save system', err);
584
- return '';
585
- });
985
+ return id;
986
+ })
987
+ .catch((err) => {
988
+ console.error('spindle: failed to init save system', err);
989
+ return '';
990
+ }),
991
+ );
586
992
  },
587
993
 
588
994
  navigate: (passageName: string) => {
@@ -616,7 +1022,7 @@ export const useStoryStore = create<StoryState>()(
616
1022
  const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
617
1023
 
618
1024
  set((state) => {
619
- state.temporary = {};
1025
+ state.temporary = createNamespace();
620
1026
  state.currentPassage = passageName;
621
1027
  state.navigationId++;
622
1028
 
@@ -635,29 +1041,20 @@ export const useStoryStore = create<StoryState>()(
635
1041
  prng: snapshotPRNG(),
636
1042
  });
637
1043
 
638
- // Trim oldest entries if over the limit
639
- const overflow = state.history.length - state.maxHistory;
640
- if (overflow > 0) {
641
- // Advance base through trimmed transitions
642
- for (let i = 0; i < overflow; i++) {
643
- variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
644
- }
645
- state.history = state.history.slice(overflow);
646
- patchEntries = patchEntries.slice(overflow);
647
- serializedHistory = serializedHistory.slice(overflow);
648
- }
649
-
650
1044
  state.historyIndex = state.history.length - 1;
651
- state.visitCounts[passageName] =
652
- (state.visitCounts[passageName] ?? 0) + 1;
653
- state.renderCounts[passageName] =
654
- (state.renderCounts[passageName] ?? 0) + 1;
1045
+ // Trim oldest entries if over the limit
1046
+ trimHistory(state);
1047
+ state.visitCounts = createCounts(state.visitCounts, passageName);
1048
+ state.renderCounts = createCounts(state.renderCounts, passageName);
655
1049
  });
656
1050
 
657
1051
  // Watchers react to the completed transition (visit counts, cleared
658
1052
  // temporaries). Like beforenavigate changes, their run actions belong
659
1053
  // to the entered moment; navigations they request run afterwards.
660
- const enteredVars = get().variables;
1054
+ enteredMoment = {
1055
+ navigationId: get().navigationId,
1056
+ variables: get().variables,
1057
+ };
661
1058
  navigationTriggerPhase = true;
662
1059
  try {
663
1060
  checkTriggersOnNavigation();
@@ -666,18 +1063,7 @@ export const useStoryStore = create<StoryState>()(
666
1063
  }
667
1064
  const deferred = deferredNavigations;
668
1065
  deferredNavigations = [];
669
- if (get().variables !== enteredVars) {
670
- rerecordNewestMoment(get().variables);
671
- }
672
- const prng = snapshotPRNG();
673
- const recorded = get().history[get().historyIndex]!.prng;
674
- if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
675
- set((state) => {
676
- state.history[state.historyIndex]!.prng = prng;
677
- });
678
- }
679
-
680
- lastNavigationVars = get().variables;
1066
+ finishEnteredMoment(get, set);
681
1067
  persistSession(get);
682
1068
 
683
1069
  emit('afternavigate', passageName, previousPassage);
@@ -688,6 +1074,7 @@ export const useStoryStore = create<StoryState>()(
688
1074
  goBack: () => {
689
1075
  const { historyIndex } = get();
690
1076
  if (historyIndex <= 0) return;
1077
+ finishEnteredMoment(get, set);
691
1078
 
692
1079
  const previousPassage = get().currentPassage;
693
1080
  const targetPassage = get().history[historyIndex - 1]!.passage;
@@ -702,7 +1089,7 @@ export const useStoryStore = create<StoryState>()(
702
1089
  state.currentPassage = state.history[state.historyIndex]!.passage;
703
1090
  state.navigationId++;
704
1091
  state.variables = restoredVars;
705
- state.temporary = {};
1092
+ state.temporary = createNamespace();
706
1093
  });
707
1094
 
708
1095
  // Restored state is not a change watchers react to
@@ -717,6 +1104,7 @@ export const useStoryStore = create<StoryState>()(
717
1104
  goForward: () => {
718
1105
  const { historyIndex, history: hist } = get();
719
1106
  if (historyIndex >= hist.length - 1) return;
1107
+ finishEnteredMoment(get, set);
720
1108
 
721
1109
  const previousPassage = get().currentPassage;
722
1110
  const targetPassage = hist[historyIndex + 1]!.passage;
@@ -731,7 +1119,7 @@ export const useStoryStore = create<StoryState>()(
731
1119
  state.currentPassage = state.history[state.historyIndex]!.passage;
732
1120
  state.navigationId++;
733
1121
  state.variables = restoredVars;
734
- state.temporary = {};
1122
+ state.temporary = createNamespace();
735
1123
  });
736
1124
 
737
1125
  // Restored state is not a change watchers react to
@@ -745,36 +1133,42 @@ export const useStoryStore = create<StoryState>()(
745
1133
 
746
1134
  setVariable: (name: string, value: unknown) => {
747
1135
  set((state) => {
1136
+ checkVariableName(name, `$${name}`);
748
1137
  state.variables[name] = value;
749
1138
  });
750
1139
  },
751
1140
 
752
1141
  setTemporary: (name: string, value: unknown) => {
753
1142
  set((state) => {
1143
+ checkVariableName(name, `_${name}`);
754
1144
  state.temporary[name] = value;
755
1145
  });
756
1146
  },
757
1147
 
758
1148
  deleteVariable: (name: string) => {
759
1149
  set((state) => {
1150
+ checkVariableName(name, `$${name}`);
760
1151
  delete state.variables[name];
761
1152
  });
762
1153
  },
763
1154
 
764
1155
  deleteTemporary: (name: string) => {
765
1156
  set((state) => {
1157
+ checkVariableName(name, `_${name}`);
766
1158
  delete state.temporary[name];
767
1159
  });
768
1160
  },
769
1161
 
770
1162
  setTransient: (name: string, value: unknown) => {
771
1163
  set((state) => {
1164
+ checkVariableName(name, `%${name}`);
772
1165
  state.transient[name] = value;
773
1166
  });
774
1167
  },
775
1168
 
776
1169
  deleteTransient: (name: string) => {
777
1170
  set((state) => {
1171
+ checkVariableName(name, `%${name}`);
778
1172
  delete state.transient[name];
779
1173
  });
780
1174
  },
@@ -787,8 +1181,7 @@ export const useStoryStore = create<StoryState>()(
787
1181
 
788
1182
  trackRender: (passageName: string) => {
789
1183
  set((state) => {
790
- state.renderCounts[passageName] =
791
- (state.renderCounts[passageName] ?? 0) + 1;
1184
+ state.renderCounts = createCounts(state.renderCounts, passageName);
792
1185
  });
793
1186
  },
794
1187
 
@@ -809,23 +1202,10 @@ export const useStoryStore = create<StoryState>()(
809
1202
 
810
1203
  // Switch to the new playthrough now, after beforerestart (whose saves
811
1204
  // belong to the game being left) and before StoryInit, so every save
812
- // issued from here on belongs to the new game. Storing its record is
813
- // queued after the previous playthrough setup.
814
- const newPlaythroughId = crypto.randomUUID();
815
- ++playthroughGeneration;
816
- set((state) => {
817
- state.playthroughId = newPlaythroughId;
818
- });
819
- const ifid = storyData.ifid;
820
- playthroughSetup = playthroughSetup
821
- .then(() => startNewPlaythrough(ifid, newPlaythroughId))
822
- .then(
823
- () => newPlaythroughId,
824
- (err) => {
825
- console.error('spindle: failed to start new playthrough', err);
826
- return newPlaythroughId;
827
- },
828
- );
1205
+ // issued from here on belongs to the new game.
1206
+ switchToNewPlaythrough(storyData.ifid);
1207
+ // A load from a slot issued before the restart must not apply
1208
+ replaceStateNow();
829
1209
 
830
1210
  const keepDeferred = get().renderDeferred;
831
1211
 
@@ -834,15 +1214,15 @@ export const useStoryStore = create<StoryState>()(
834
1214
 
835
1215
  resetPRNG();
836
1216
  resetTriggers();
837
- const initialVars = deepClone(variableDefaults);
1217
+ const initialVars = createNamespace(deepClone(variableDefaults));
838
1218
  resetModuleState(deepClone(initialVars));
839
1219
 
840
1220
  set((state) => {
841
1221
  state.currentPassage = startPassage.name;
842
1222
  state.navigationId++;
843
1223
  state.variables = initialVars;
844
- state.transient = deepClone(transientDefaults);
845
- state.temporary = {};
1224
+ state.transient = createNamespace(deepClone(transientDefaults));
1225
+ state.temporary = createNamespace();
846
1226
  state.history = [
847
1227
  {
848
1228
  passage: startPassage.name,
@@ -850,8 +1230,8 @@ export const useStoryStore = create<StoryState>()(
850
1230
  },
851
1231
  ];
852
1232
  state.historyIndex = 0;
853
- state.visitCounts = { [startPassage.name]: 1 };
854
- state.renderCounts = { [startPassage.name]: 1 };
1233
+ state.visitCounts = createCounts(null, startPassage.name);
1234
+ state.renderCounts = createCounts(null, startPassage.name);
855
1235
  if (!keepDeferred) {
856
1236
  state.renderDeferred = false;
857
1237
  }
@@ -880,8 +1260,8 @@ export const useStoryStore = create<StoryState>()(
880
1260
  set((state) => {
881
1261
  state.saveError = null;
882
1262
  });
883
- const playthroughId = await playthrough;
884
- await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
1263
+ // Queued now, in call order with other storage operations
1264
+ await quickSave(storyData.ifid, playthrough, payload, slot, custom);
885
1265
  set((state) => {
886
1266
  state.knownSaves = {
887
1267
  ...state.knownSaves,
@@ -891,8 +1271,7 @@ export const useStoryStore = create<StoryState>()(
891
1271
  }).catch((err) => {
892
1272
  console.error('spindle: failed to save', err);
893
1273
  set((state) => {
894
- state.saveError =
895
- err instanceof Error ? err.message : 'Failed to save';
1274
+ state.saveError = errorMessage(err, 'Failed to save');
896
1275
  });
897
1276
  throw err;
898
1277
  }),
@@ -906,17 +1285,35 @@ export const useStoryStore = create<StoryState>()(
906
1285
  set((state) => {
907
1286
  state.loadError = null;
908
1287
  });
1288
+ // The game moves to the loaded save's playthrough in call order: the
1289
+ // read, queued now, makes it the stored current playthrough, and saves
1290
+ // issued after the load belong to it (a later restart or load moves
1291
+ // the game on, as usual). An empty slot leaves the playthrough as it
1292
+ // is.
1293
+ const previous = resolvePlaythroughId();
1294
+ const read = loadSlotSave(storyData.ifid, slot);
1295
+ const switched = switchToLookedUpPlaythrough(
1296
+ Promise.all([previous, read.catch(() => undefined)]).then(
1297
+ ([prev, loaded]) => loaded?.playthroughId || prev,
1298
+ ),
1299
+ );
1300
+ const replacement = ++stateReplacementsIssued;
909
1301
  return handled(
910
- loadQuickSave(storyData.ifid, slot)
911
- .then((payload) => {
912
- if (!payload) return;
913
- get().loadFromPayload(payload, slot);
1302
+ read
1303
+ .then(async (loaded) => {
1304
+ // The store names the loaded playthrough before the loaded
1305
+ // state is applied (and `afterload` fires)
1306
+ await switched;
1307
+ if (!loaded) return;
1308
+ // A restart, boot or direct load issued after this one won
1309
+ if (latestStateApplied > replacement) return;
1310
+ slotLoadApplying = replacement;
1311
+ get().loadFromPayload(loaded.payload, slot);
914
1312
  })
915
1313
  .catch((err) => {
916
1314
  console.error('spindle: failed to load save', err);
917
1315
  set((state) => {
918
- state.loadError =
919
- err instanceof Error ? err.message : 'Failed to load';
1316
+ state.loadError = errorMessage(err, 'Failed to load');
920
1317
  });
921
1318
  throw err;
922
1319
  }),
@@ -982,50 +1379,83 @@ export const useStoryStore = create<StoryState>()(
982
1379
 
983
1380
  clearGameData: () => {
984
1381
  const { storyData } = get();
985
- if (!storyData) return;
1382
+ if (!storyData) return Promise.resolve();
986
1383
 
987
- smClearGameData(storyData.ifid)
988
- .then(() => {
989
- set((state) => {
990
- state.knownSaves = {};
991
- });
992
- get().restart();
993
- })
994
- .catch((err) => {
995
- console.error('spindle: failed to clear game data', err);
1384
+ // Queue the clearing, then restart now: the new playthrough is stored
1385
+ // after it, and operations issued from here on belong to the new game.
1386
+ // The slot cache empties once the clearing is done, after operations
1387
+ // issued before it have updated it.
1388
+ const cleared = smClearGameData(storyData.ifid).then(() => {
1389
+ set((state) => {
1390
+ state.knownSaves = {};
996
1391
  });
1392
+ });
1393
+ get().restart();
1394
+ return handled(
1395
+ cleared.catch((err) => {
1396
+ console.error('spindle: failed to clear game data', err);
1397
+ throw err;
1398
+ }),
1399
+ );
997
1400
  },
998
1401
 
999
1402
  clearAllData: () => {
1000
- const { storyData } = get();
1001
- if (!storyData) return;
1002
-
1003
- smClearAllData()
1004
- .then(() => {
1005
- set((state) => {
1006
- state.knownSaves = {};
1007
- });
1008
- get().restart();
1009
- })
1010
- .catch((err) => {
1011
- console.error('spindle: failed to clear all data', err);
1403
+ // As clearGameData: queue the clearing, then restart now
1404
+ const cleared = smClearAllData().then(() => {
1405
+ set((state) => {
1406
+ state.knownSaves = {};
1012
1407
  });
1408
+ });
1409
+ get().restart();
1410
+ return handled(
1411
+ cleared.catch((err) => {
1412
+ console.error('spindle: failed to clear all data', err);
1413
+ throw err;
1414
+ }),
1415
+ );
1013
1416
  },
1014
1417
 
1015
1418
  deletePlaythrough: (playthroughId: string) => {
1016
1419
  const { storyData } = get();
1017
- if (!storyData) return;
1420
+ if (!storyData) return Promise.resolve();
1018
1421
 
1019
- smDeletePlaythroughData(storyData.ifid, playthroughId)
1020
- .then(async () => {
1021
- const known = await populateKnownSaves(storyData.ifid);
1022
- set((state) => {
1023
- state.knownSaves = known;
1024
- });
1025
- })
1026
- .catch((err) => {
1027
- console.error('spindle: failed to delete playthrough', err);
1028
- });
1422
+ // The running game can't go on in a deleted playthrough: its later
1423
+ // saves would belong to no playthrough. It moves to a new one, as on
1424
+ // restart but keeping its state. While the game's playthrough is not
1425
+ // known yet (init is looking it up, or a load from a slot is reading
1426
+ // the save that decides it), the deletion checks the one established.
1427
+ const ifid = storyData.ifid;
1428
+ const current = knownPlaythroughId();
1429
+ const established = playthroughSetup;
1430
+ const replacementId = crypto.randomUUID();
1431
+ const deletion = smDeletePlaythroughData(ifid, playthroughId, {
1432
+ current: current || established,
1433
+ id: replacementId,
1434
+ });
1435
+ if (playthroughId !== '' && playthroughId === current) {
1436
+ switchToPlaythrough(replacementId, deletion);
1437
+ } else if (current === '') {
1438
+ switchToLookedUpPlaythrough(
1439
+ deletion.then(
1440
+ (replaced) => (replaced ? replacementId : established),
1441
+ () => established,
1442
+ ),
1443
+ );
1444
+ }
1445
+
1446
+ return handled(
1447
+ deletion
1448
+ .then(async () => {
1449
+ const known = await populateKnownSaves(storyData.ifid);
1450
+ set((state) => {
1451
+ state.knownSaves = known;
1452
+ });
1453
+ })
1454
+ .catch((err) => {
1455
+ console.error('spindle: failed to delete playthrough', err);
1456
+ throw err;
1457
+ }),
1458
+ );
1029
1459
  },
1030
1460
 
1031
1461
  getSavePayload: (): SavePayload => {
@@ -1047,7 +1477,7 @@ export const useStoryStore = create<StoryState>()(
1047
1477
  }
1048
1478
  saveHistory.push({
1049
1479
  passage: history[i]!.passage,
1050
- variables: deepClone(vars),
1480
+ variables: plainCopy(vars),
1051
1481
  timestamp: history[i]!.timestamp,
1052
1482
  prng: history[i]!.prng,
1053
1483
  });
@@ -1055,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
1055
1485
 
1056
1486
  return {
1057
1487
  passage: currentPassage,
1058
- variables: deepClone(variables),
1488
+ variables: plainCopy(variables),
1059
1489
  history: saveHistory,
1060
1490
  historyIndex,
1061
1491
  visitCounts: { ...visitCounts },
@@ -1073,14 +1503,29 @@ export const useStoryStore = create<StoryState>()(
1073
1503
  };
1074
1504
  },
1075
1505
 
1076
- loadFromPayload: (payload: SavePayload, slot?: string) => {
1506
+ loadFromPayload: (
1507
+ payload: SavePayload,
1508
+ slot?: string,
1509
+ playthroughId?: string,
1510
+ ) => {
1511
+ const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
1512
+ slotLoadApplying = null;
1077
1513
  if (payload.history.length === 0) {
1078
1514
  console.warn('loadFromPayload: rejecting payload with empty history');
1079
1515
  return;
1080
1516
  }
1517
+ latestStateApplied = replacement;
1081
1518
 
1082
1519
  emit('beforeload', slot);
1083
1520
 
1521
+ // Loading a save moves the game to the save's playthrough, after the
1522
+ // `beforeload` handlers (whose saves belong to the game being left).
1523
+ // Restoring the session passes none: the game stays in its playthrough.
1524
+ const ifid = get().storyData?.ifid;
1525
+ if (playthroughId && ifid) {
1526
+ switchToLoadedPlaythrough(ifid, playthroughId);
1527
+ }
1528
+
1084
1529
  // Restore the state on entering the saved passage, not the payload's
1085
1530
  // live variables: the passage remounts and runs its {set}/{do} again,
1086
1531
  // so restoring their results as well would apply them twice. Changes
@@ -1091,12 +1536,16 @@ export const useStoryStore = create<StoryState>()(
1091
1536
  // The payload is already live (deserialized at the storage boundary by
1092
1537
  // loadSave/loadSession); deserializing again would corrupt built-ins.
1093
1538
  // Convert full snapshots to patch entries
1094
- const base = deepClone(payload.history[0]?.variables ?? {});
1539
+ const base = createNamespace(
1540
+ deepClone(payload.history[0]?.variables ?? {}),
1541
+ );
1095
1542
  const newPatchEntries: PatchEntry[] = [];
1096
1543
 
1097
1544
  let prevVars: Record<string, unknown> = base;
1098
1545
  for (let i = 1; i < payload.history.length; i++) {
1099
- const currVars = deepClone(payload.history[i]!.variables);
1546
+ const currVars = createNamespace(
1547
+ deepClone(payload.history[i]!.variables),
1548
+ );
1100
1549
  newPatchEntries.push(computeVarPatches(prevVars, currVars));
1101
1550
  prevVars = currVars;
1102
1551
  }
@@ -1115,7 +1564,9 @@ export const useStoryStore = create<StoryState>()(
1115
1564
  set((state) => {
1116
1565
  state.currentPassage = payload.passage;
1117
1566
  state.navigationId++;
1118
- state.variables = deepClone(entry?.variables ?? payload.variables);
1567
+ state.variables = createNamespace(
1568
+ deepClone(entry?.variables ?? payload.variables),
1569
+ );
1119
1570
  state.history = payload.history.map((m) => ({
1120
1571
  passage: m.passage,
1121
1572
  timestamp: m.timestamp,
@@ -1125,10 +1576,12 @@ export const useStoryStore = create<StoryState>()(
1125
1576
  0,
1126
1577
  Math.min(payload.historyIndex, state.history.length - 1),
1127
1578
  );
1128
- state.visitCounts = payload.visitCounts ?? {};
1129
- state.renderCounts = payload.renderCounts ?? {};
1130
- state.temporary = {};
1131
- state.transient = deepClone(get().transientDefaults);
1579
+ // A save made under a higher limit keeps no more than the limit
1580
+ trimHistory(state);
1581
+ state.visitCounts = createCounts(payload.visitCounts);
1582
+ state.renderCounts = createCounts(payload.renderCounts);
1583
+ state.temporary = createNamespace();
1584
+ state.transient = createNamespace(deepClone(get().transientDefaults));
1132
1585
  });
1133
1586
 
1134
1587
  // Loaded state is not a change watchers react to
@@ -1150,7 +1603,8 @@ export const useStoryStore = create<StoryState>()(
1150
1603
  // Write the loaded game to the session so a refresh restores it
1151
1604
  persistSession(get);
1152
1605
 
1153
- emit('afterload', slot);
1606
+ // Draws made by the handlers would shift the passage's replayed rolls
1607
+ withoutDraws(() => emit('afterload', slot));
1154
1608
  },
1155
1609
 
1156
1610
  getHistoryVariables: (index: number): Record<string, unknown> => {