@rohal12/spindle 0.51.4 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4313 -1640
  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/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +214 -103
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +5 -31
  12. package/src/components/macros/Checkbox.tsx +7 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/For.tsx +29 -3
  15. package/src/components/macros/If.tsx +8 -0
  16. package/src/components/macros/Include.tsx +7 -6
  17. package/src/components/macros/MacroError.tsx +2 -1
  18. package/src/components/macros/MacroLink.tsx +12 -45
  19. package/src/components/macros/Meter.tsx +11 -3
  20. package/src/components/macros/Nobr.tsx +1 -0
  21. package/src/components/macros/PassageDisplay.tsx +3 -0
  22. package/src/components/macros/Print.tsx +4 -0
  23. package/src/components/macros/Radiobutton.tsx +5 -2
  24. package/src/components/macros/SaveManager.tsx +25 -8
  25. package/src/components/macros/Span.tsx +1 -0
  26. package/src/components/macros/StoryTitle.tsx +1 -0
  27. package/src/components/macros/Switch.tsx +13 -0
  28. package/src/components/macros/Unset.tsx +30 -10
  29. package/src/components/macros/VarDisplay.tsx +21 -4
  30. package/src/components/macros/Widget.tsx +20 -1
  31. package/src/components/macros/WidgetInvocation.tsx +17 -75
  32. package/src/components/macros/arg-utils.ts +107 -1
  33. package/src/components/macros/detached-body.tsx +68 -0
  34. package/src/components/macros/option-utils.ts +3 -2
  35. package/src/define-macro.ts +32 -5
  36. package/src/execute-mutation.ts +270 -67
  37. package/src/expression.ts +86 -55
  38. package/src/hooks/use-action.ts +18 -3
  39. package/src/hooks/use-interpolate.ts +36 -5
  40. package/src/index.tsx +10 -1
  41. package/src/interpolation.ts +394 -96
  42. package/src/js-lexer.ts +1231 -97
  43. package/src/markup/code-attributes.ts +64 -0
  44. package/src/markup/markdown.ts +188 -9
  45. package/src/markup/render.tsx +430 -49
  46. package/src/markup/tokenizer.ts +578 -110
  47. package/src/prng.ts +8 -8
  48. package/src/registry.ts +35 -0
  49. package/src/saves/save-manager.ts +339 -158
  50. package/src/saves/storage.ts +16 -7
  51. package/src/saves/types.ts +2 -1
  52. package/src/store.ts +521 -154
  53. package/src/story-api.ts +31 -67
  54. package/src/story-init.ts +1 -1
  55. package/src/story-variables.ts +98 -102
  56. package/src/triggers.ts +6 -5
  57. package/src/utils/error-message.ts +12 -0
  58. package/src/utils/live-locals.ts +10 -3
  59. package/src/utils/namespace.ts +71 -0
  60. package/src/utils/object-path.ts +99 -14
  61. package/src/utils/stable-key.ts +14 -9
  62. package/src/widgets/widget-registry.ts +9 -0
  63. package/types/index.d.ts +43 -7
  64. 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,
@@ -48,8 +53,22 @@ import {
48
53
  resetPRNG,
49
54
  type PRNGSnapshot,
50
55
  } from './prng';
56
+ import { errorMessage } from './utils/error-message';
57
+ import {
58
+ routeStoreUpdate,
59
+ runWithCommittedMutations,
60
+ } from './execute-mutation';
61
+ import {
62
+ checkVariableName,
63
+ createNamespace,
64
+ isNamespace,
65
+ type Namespace,
66
+ } from './utils/namespace';
51
67
 
52
68
  enablePatches();
69
+ // Story state holds Map and Set values: Immer must be able to draft them
70
+ // when a write (a dot path, a macro binding) reaches one
71
+ enableMapSet();
53
72
 
54
73
  const SPECIAL_PASSAGES = new Set([
55
74
  'StoryInit',
@@ -74,7 +93,7 @@ interface PatchEntry {
74
93
  }
75
94
 
76
95
  /** Full variable snapshot at history index 0. */
77
- let variableBase: Record<string, unknown> = {};
96
+ let variableBase: Namespace = createNamespace();
78
97
 
79
98
  /**
80
99
  * Transitions between consecutive history moments.
@@ -84,7 +103,7 @@ let variableBase: Record<string, unknown> = {};
84
103
  let patchEntries: PatchEntry[] = [];
85
104
 
86
105
  /** Immer-produced reference to variables right after the last navigation. */
87
- let lastNavigationVars: Record<string, unknown> = {};
106
+ let lastNavigationVars: Namespace = createNamespace();
88
107
 
89
108
  /** Deep-clone patch values so they are independent of future mutations. */
90
109
  function clonePatches(patches: Patch[]): Patch[] {
@@ -102,7 +121,9 @@ function computeVarPatches(
102
121
  const [, forward, inverse] = produceWithPatches(prev, (draft) => {
103
122
  const d = draft as Record<string, unknown>;
104
123
  for (const key of Object.keys(d)) {
105
- if (!(key in curr)) delete d[key];
124
+ // Own keys only: `curr` may be a plain object (a loaded snapshot),
125
+ // whose inherited `constructor` is no variable
126
+ if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
106
127
  }
107
128
  for (const [key, val] of Object.entries(curr)) {
108
129
  d[key] = val;
@@ -197,14 +218,88 @@ function persistSession(get: () => StoryState): void {
197
218
  });
198
219
  }
199
220
 
221
+ /**
222
+ * Trim history to `state.maxHistory` moments: keep the newest moments that
223
+ * include the current one. After a navigation (the current moment is the
224
+ * newest) that drops the oldest; when the player has gone back further than
225
+ * the limit allows, the moments after the newest kept one are dropped too.
226
+ * Call it inside a store update; the module-level variable history
227
+ * (base, patches, session cache) is trimmed alongside.
228
+ */
229
+ function trimHistory(state: {
230
+ history: HistoryMoment[];
231
+ historyIndex: number;
232
+ maxHistory: number;
233
+ }): boolean {
234
+ const excess = state.history.length - state.maxHistory;
235
+ if (excess <= 0) return false;
236
+ const start = Math.min(state.historyIndex, excess);
237
+ const end = start + state.maxHistory;
238
+ // Advance base through trimmed transitions
239
+ for (let i = 0; i < start; i++) {
240
+ variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
241
+ }
242
+ state.history = state.history.slice(start, end);
243
+ patchEntries = patchEntries.slice(start, end - 1);
244
+ serializedHistory = serializedHistory.slice(start, end);
245
+ state.historyIndex -= start;
246
+ return true;
247
+ }
248
+
200
249
  /** True while navigate() lets watchers react to the moment it entered. */
201
250
  let navigationTriggerPhase = false;
202
251
 
203
252
  /** Navigations requested during the trigger phase, run once it is over. */
204
253
  let deferredNavigations: string[] = [];
205
254
 
255
+ /**
256
+ * The moment navigate() entered, while its watchers may still change it:
257
+ * the navigation it belongs to and the variables it was recorded with.
258
+ */
259
+ let enteredMoment: {
260
+ navigationId: number;
261
+ variables: Record<string, unknown>;
262
+ } | null = null;
263
+
264
+ /**
265
+ * Record the entered moment as it is now (watcher run actions and the PRNG
266
+ * rolls they made belong to it), unless the story has left it already.
267
+ * navigate() calls this after its watchers, and back/forward before they
268
+ * leave the moment: a watcher that moves through history must not have the
269
+ * moment it leaves recorded with the state of the one it arrives at.
270
+ */
271
+ function finishEnteredMoment(
272
+ get: () => StoryState,
273
+ set: (recipe: (state: StoryState) => void) => void,
274
+ ): void {
275
+ const moment = enteredMoment;
276
+ enteredMoment = null;
277
+ if (!moment || get().navigationId !== moment.navigationId) return;
278
+ if (get().variables !== moment.variables) {
279
+ rerecordNewestMoment(get().variables);
280
+ }
281
+ // The next navigate() diffs from this recorded snapshot. A watcher that
282
+ // left the moment has set it to the snapshot of the one it went to.
283
+ lastNavigationVars = get().variables;
284
+ const prng = snapshotPRNG();
285
+ const recorded = get().history[get().historyIndex]!.prng;
286
+ if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
287
+ set((state) => {
288
+ state.history[state.historyIndex]!.prng = prng;
289
+ });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * A deep copy of a variable namespace as save data: a plain object, as a
295
+ * loaded save holds it (the store turns it back into a namespace on load).
296
+ */
297
+ const plainCopy = (ns: Namespace): Record<string, unknown> => ({
298
+ ...deepClone(ns),
299
+ });
300
+
206
301
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
207
- function resetModuleState(base: Record<string, unknown>): void {
302
+ function resetModuleState(base: Namespace): void {
208
303
  variableBase = base;
209
304
  patchEntries = [];
210
305
  lastNavigationVars = base;
@@ -239,29 +334,139 @@ export function recordStoryInitState(): void {
239
334
  // ---------------------------------------------------------------------------
240
335
 
241
336
  /**
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.
337
+ * Settles once the latest playthrough switch (init's lookup or creation, a
338
+ * restart's creation, the replacement of a deleted current playthrough, a
339
+ * load making the loaded save's playthrough current) is stored, with the
340
+ * playthrough ID it leaves the game in ('' if init could not establish one).
341
+ * Switches are storage operations, which run in call order, so playthroughs
342
+ * are created and numbered in the order the game started them, and a save
343
+ * issued after a switch is stored after it, in the playthrough it switched
344
+ * to.
246
345
  */
247
346
  let playthroughSetup: Promise<string> = Promise.resolve('');
248
347
 
249
- /** Bumped by every init()/restart(); a stale init must not adopt its ID. */
348
+ /**
349
+ * Bumped by every playthrough switch; a switch that settles after a later
350
+ * one was issued must not set its ID.
351
+ */
250
352
  let playthroughGeneration = 0;
251
353
 
354
+ /**
355
+ * Whether the latest switch is to a playthrough not known until a storage
356
+ * operation has run: the one init looks up, the playthrough of the save a
357
+ * load from a slot reads, or (while one of those is pending) the one a
358
+ * playthrough deletion leaves the game in. Meanwhile the store's
359
+ * `playthroughId` is the one before ('' at boot), and saves issued take the
360
+ * one the switch establishes.
361
+ */
362
+ let playthroughPending = false;
363
+
364
+ /** The game's playthrough now, or '' while a pending switch decides it. */
365
+ function knownPlaythroughId(): string {
366
+ return playthroughPending ? '' : useStoryStore.getState().playthroughId;
367
+ }
368
+
252
369
  /**
253
370
  * The playthrough a save issued now belongs to, once its record is stored.
254
371
  * Read synchronously at the call: restart() switches the store's
255
372
  * `playthroughId` at once, so a save issued after it (even before the new
256
373
  * 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.
374
+ * doesn't move it. While a switch whose playthrough is not known yet is
375
+ * pending (init's lookup, a load from a slot), the save takes the one that
376
+ * switch establishes.
259
377
  */
260
378
  export function resolvePlaythroughId(): Promise<string> {
261
- const current = useStoryStore.getState().playthroughId;
379
+ const current = knownPlaythroughId();
262
380
  return playthroughSetup.then((established) => current || established);
263
381
  }
264
382
 
383
+ function setPlaythroughId(id: string): void {
384
+ if (useStoryStore.getState().playthroughId === id) return;
385
+ useStoryStore.setState((state) => {
386
+ state.playthroughId = id;
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Switch to the playthrough `lookup` (a storage operation queued now)
392
+ * resolves to. Saves issued meanwhile belong to it; the store's
393
+ * `playthroughId` is set once it is known, unless a later switch was issued.
394
+ */
395
+ function switchToLookedUpPlaythrough(lookup: Promise<string>): Promise<string> {
396
+ const generation = ++playthroughGeneration;
397
+ playthroughPending = true;
398
+ playthroughSetup = lookup.then((id) => {
399
+ if (generation === playthroughGeneration) {
400
+ playthroughPending = false;
401
+ setPlaythroughId(id);
402
+ }
403
+ return id;
404
+ });
405
+ return playthroughSetup;
406
+ }
407
+
408
+ /**
409
+ * Move the running game to the playthrough `id` at once: saves issued from
410
+ * here on belong to it. `stored` is the storage operation recording the
411
+ * switch, queued now, after those already issued (its failure is reported
412
+ * by the caller).
413
+ */
414
+ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
415
+ ++playthroughGeneration;
416
+ playthroughPending = false;
417
+ playthroughSetup = stored.then(
418
+ () => id,
419
+ () => id,
420
+ );
421
+ setPlaythroughId(id);
422
+ }
423
+
424
+ /** Move the running game to a new playthrough at once (see restart). */
425
+ function switchToNewPlaythrough(ifid: string): void {
426
+ const id = crypto.randomUUID();
427
+ const stored = startNewPlaythrough(ifid, id).catch((err) => {
428
+ console.error('spindle: failed to start new playthrough', err);
429
+ });
430
+ switchToPlaythrough(id, stored);
431
+ }
432
+
433
+ /**
434
+ * Move the running game to the playthrough of a save it loads, at once. A
435
+ * no-op if the game is in it already.
436
+ */
437
+ function switchToLoadedPlaythrough(ifid: string, id: string): void {
438
+ if (knownPlaythroughId() === id) return;
439
+ const stored = adoptPlaythrough(ifid, id).catch((err) => {
440
+ console.error('spindle: failed to switch to the loaded playthrough', err);
441
+ });
442
+ switchToPlaythrough(id, stored);
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Superseded loads
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * A load from a slot reads the save in the order of storage operations and
451
+ * applies it when the read completes. A restart, a boot or a direct load
452
+ * (loadFromPayload) issued after it replaces the game state at once; the
453
+ * slot load, completing later, must not undo that. Every replacement of the
454
+ * game state takes a number in call order, and a slot load applies only if
455
+ * no replacement issued after it has been applied. Loads from slots apply in
456
+ * call order anyway (their reads are queued), so they never supersede one
457
+ * another.
458
+ */
459
+ let stateReplacementsIssued = 0;
460
+ let latestStateApplied = 0;
461
+
462
+ /** The number of the slot load that is calling loadFromPayload. */
463
+ let slotLoadApplying: number | null = null;
464
+
465
+ /** A replacement of the game state applied at its call. */
466
+ function replaceStateNow(): void {
467
+ latestStateApplied = ++stateReplacementsIssued;
468
+ }
469
+
265
470
  // ---------------------------------------------------------------------------
266
471
  // Runtime handler cleanup (auto-unsub on restart)
267
472
  // ---------------------------------------------------------------------------
@@ -392,6 +597,12 @@ export interface StoryState {
392
597
  visitCounts: Record<string, number>;
393
598
  renderCounts: Record<string, number>;
394
599
  knownSaves: Record<string, true>;
600
+ /**
601
+ * The playthrough the running game is in: its saves are grouped under it.
602
+ * Set by boot (the stored current playthrough), restart (a new one),
603
+ * loading a save (the save's) and deleting the current playthrough (a new
604
+ * one). '' until boot has looked it up.
605
+ */
395
606
  playthroughId: string;
396
607
  maxHistory: number;
397
608
  quickSaveKey: string | null;
@@ -428,6 +639,12 @@ export interface StoryState {
428
639
  trackRender: (passageName: string) => void;
429
640
  restart: () => void;
430
641
  save: (slot?: string, custom?: Record<string, unknown>) => Promise<void>;
642
+ /**
643
+ * Load the save in a slot and move the game to its playthrough. The switch
644
+ * takes effect in call order (a save issued after the load belongs to the
645
+ * loaded playthrough); the state is applied when the save has been read,
646
+ * unless a restart or another direct load was issued after this load.
647
+ */
431
648
  load: (slot?: string) => Promise<void>;
432
649
  hasSave: (slot?: string) => boolean;
433
650
  getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
@@ -435,9 +652,18 @@ export interface StoryState {
435
652
  deleteSave: (slot?: string) => Promise<void>;
436
653
  exportSave: (slot?: string) => Promise<SaveExport | null>;
437
654
  importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
438
- clearGameData: () => void;
439
- clearAllData: () => void;
440
- deletePlaythrough: (playthroughId: string) => void;
655
+ /**
656
+ * Delete the story's saves and playthroughs and restart. The restart is
657
+ * immediate; the promise settles once the data is deleted.
658
+ */
659
+ clearGameData: () => Promise<void>;
660
+ /** As clearGameData, for all Spindle data (every story). */
661
+ clearAllData: () => Promise<void>;
662
+ /**
663
+ * Delete a playthrough and its saves. Deleting the current one moves the
664
+ * running game to a new playthrough.
665
+ */
666
+ deletePlaythrough: (playthroughId: string) => Promise<void>;
441
667
  getSavePayload: () => SavePayload;
442
668
  /**
443
669
  * Start capturing a save, before the `beforesave` hooks run. The returned
@@ -447,9 +673,15 @@ export interface StoryState {
447
673
  beginSave: () => () => SavePayload;
448
674
  /**
449
675
  * Replace the game state with a live (deserialized) payload. `slot` is
450
- * passed to the `beforeload`/`afterload` events.
676
+ * passed to the `beforeload`/`afterload` events. Pass the save's
677
+ * `playthroughId` when loading a save: the game moves to that playthrough
678
+ * (no change if it is the current one). Restoring the session passes none.
451
679
  */
452
- loadFromPayload: (payload: SavePayload, slot?: string) => void;
680
+ loadFromPayload: (
681
+ payload: SavePayload,
682
+ slot?: string,
683
+ playthroughId?: string,
684
+ ) => void;
453
685
  getHistoryVariables: (index: number) => Record<string, unknown>;
454
686
  setTransition: (config: TransitionConfig | null) => void;
455
687
  setNextTransition: (config: TransitionConfig | null) => void;
@@ -458,6 +690,94 @@ export interface StoryState {
458
690
  clearDeferredRender: () => void;
459
691
  }
460
692
 
693
+ const NAMESPACE_KEYS = ['variables', 'temporary', 'transient'] as const;
694
+
695
+ type StoryRecipe = (draft: Draft<StoryState>) => void;
696
+
697
+ /** Replace a namespace an update left with a prototype by one without. */
698
+ function keepNamespacesBare(draft: Draft<StoryState>): void {
699
+ for (const key of NAMESPACE_KEYS) {
700
+ const ns = draft[key];
701
+ if (!isNamespace(ns)) {
702
+ draft[key] = createNamespace(isDraft(ns) ? current(ns) : ns);
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Actions that record, replace or save story state. Called while mutation
709
+ * code runs (by Story.goto, a {link} or {back} the code performs, a
710
+ * watcher), they act in program order: the code's writes so far are
711
+ * committed first, and the code goes on from the state they leave (see
712
+ * runWithCommittedMutations). Outside mutation code they just run.
713
+ *
714
+ * A load from a slot (`load`) takes its place among the save operations
715
+ * (and switches playthroughs) at the call, but applies the save when its
716
+ * read completes, after the code has run.
717
+ */
718
+ const PROGRAM_ORDER_ACTIONS = [
719
+ 'navigate',
720
+ 'goBack',
721
+ 'goForward',
722
+ 'restart',
723
+ 'save',
724
+ 'load',
725
+ 'getSavePayload',
726
+ 'loadFromPayload',
727
+ ] as const;
728
+
729
+ /**
730
+ * Store middleware (inside `immer`) that every update goes through: the
731
+ * store's own actions and outside `setState` calls alike.
732
+ *
733
+ * - The variable namespaces stay records without a prototype, whatever an
734
+ * update assigns (see utils/namespace.ts).
735
+ * - An update made while mutation code runs follows that code's pending
736
+ * writes, in program order, and reaches its working copies (see
737
+ * routeStoreUpdate in execute-mutation.ts).
738
+ * - The PROGRAM_ORDER_ACTIONS commit running mutation code first.
739
+ */
740
+ function storyStateGuard(
741
+ creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
742
+ ): StateCreator<StoryState, [['zustand/immer', never]], []> {
743
+ return (set, get, api) => {
744
+ const guarded = ((
745
+ updater: Partial<StoryState> | StoryRecipe,
746
+ replace?: boolean,
747
+ ) => {
748
+ if (replace) {
749
+ (set as (u: unknown, r: true) => void)(updater, true);
750
+ return;
751
+ }
752
+ const recipe: StoryRecipe =
753
+ typeof updater === 'function'
754
+ ? updater
755
+ : (draft) => {
756
+ Object.assign(draft, updater);
757
+ };
758
+ const routed = routeStoreUpdate(recipe);
759
+ set((draft) => {
760
+ (routed ?? recipe)(draft);
761
+ keepNamespacesBare(draft);
762
+ });
763
+ }) as typeof set;
764
+ api.setState = guarded;
765
+ const state = creator(guarded, get, api);
766
+ for (const name of PROGRAM_ORDER_ACTIONS) {
767
+ const action = state[name] as (...args: unknown[]) => unknown;
768
+ (state as unknown as Record<string, unknown>)[name] = (
769
+ ...args: unknown[]
770
+ ) => runWithCommittedMutations(() => action(...args));
771
+ }
772
+ return state;
773
+ };
774
+ }
775
+
776
+ /** The story store's middleware: Immer updates, guarded (see above). */
777
+ const storyStore = (
778
+ creator: StateCreator<StoryState, [['zustand/immer', never]], []>,
779
+ ) => immer(storyStateGuard(creator));
780
+
461
781
  /**
462
782
  * Return `p` marked as handled: a caller that ignores the result of a
463
783
  * fire-and-forget operation (whose failure is already logged) gets no
@@ -470,15 +790,15 @@ function handled<T>(p: Promise<T>): Promise<T> {
470
790
  }
471
791
 
472
792
  export const useStoryStore = create<StoryState>()(
473
- immer((set, get) => ({
793
+ storyStore((set, get) => ({
474
794
  storyData: null,
475
795
  currentPassage: '',
476
796
  navigationId: 0,
477
- variables: {},
797
+ variables: createNamespace(),
478
798
  variableDefaults: {},
479
- transient: {},
799
+ transient: createNamespace(),
480
800
  transientDefaults: {},
481
- temporary: {},
801
+ temporary: createNamespace(),
482
802
  history: [],
483
803
  historyIndex: -1,
484
804
  visitCounts: {},
@@ -496,9 +816,13 @@ export const useStoryStore = create<StoryState>()(
496
816
  renderDeferred: false,
497
817
 
498
818
  setMaxHistory: (limit: number) => {
819
+ let trimmed = false;
499
820
  set((state) => {
500
821
  state.maxHistory = Math.max(1, Math.round(limit));
822
+ // A lower limit takes effect at once
823
+ trimmed = trimHistory(state);
501
824
  });
825
+ if (trimmed) persistSession(get);
502
826
  },
503
827
 
504
828
  setQuickSaveKey: (key: string | null) => {
@@ -525,7 +849,7 @@ export const useStoryStore = create<StoryState>()(
525
849
  );
526
850
  }
527
851
 
528
- const initialVars = deepClone(variableDefaults);
852
+ const initialVars = createNamespace(deepClone(variableDefaults));
529
853
  resetModuleState(deepClone(initialVars));
530
854
 
531
855
  set((state) => {
@@ -536,9 +860,9 @@ export const useStoryStore = create<StoryState>()(
536
860
  state.navigationId++;
537
861
  state.variables = initialVars;
538
862
  state.variableDefaults = variableDefaults;
539
- state.transient = deepClone(transientDefaults);
863
+ state.transient = createNamespace(deepClone(transientDefaults));
540
864
  state.transientDefaults = transientDefaults;
541
- state.temporary = {};
865
+ state.temporary = createNamespace();
542
866
  state.history = [
543
867
  {
544
868
  passage: startPassage.name,
@@ -553,36 +877,29 @@ export const useStoryStore = create<StoryState>()(
553
877
  // Update lastNavigationVars to the Immer-produced reference
554
878
  lastNavigationVars = get().variables;
555
879
 
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) {
880
+ replaceStateNow();
881
+
882
+ // Look up the story's playthrough and saves in the background, as a
883
+ // storage operation queued now: saves issued meanwhile are stored
884
+ // after it, tagged with the playthrough it establishes (see
885
+ // resolvePlaythroughId). The current playthrough is stored, so a page
886
+ // refresh stays in the playthrough the game was in, also after a load
887
+ // switched to the loaded save's.
888
+ switchToLookedUpPlaythrough(
889
+ establishPlaythrough(storyData.ifid)
890
+ .then(({ id, knownSaves }) => {
891
+ // So hasSave() works after a reload. Operations issued later
892
+ // update the cache after this.
568
893
  set((state) => {
569
- state.playthroughId = id;
894
+ state.knownSaves = knownSaves;
570
895
  });
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) {
576
- set((state) => {
577
- state.knownSaves = saves;
578
- });
579
- }
580
- return id;
581
- })
582
- .catch((err) => {
583
- console.error('spindle: failed to init save system', err);
584
- return '';
585
- });
896
+ return id;
897
+ })
898
+ .catch((err) => {
899
+ console.error('spindle: failed to init save system', err);
900
+ return '';
901
+ }),
902
+ );
586
903
  },
587
904
 
588
905
  navigate: (passageName: string) => {
@@ -616,7 +933,7 @@ export const useStoryStore = create<StoryState>()(
616
933
  const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
617
934
 
618
935
  set((state) => {
619
- state.temporary = {};
936
+ state.temporary = createNamespace();
620
937
  state.currentPassage = passageName;
621
938
  state.navigationId++;
622
939
 
@@ -635,19 +952,9 @@ export const useStoryStore = create<StoryState>()(
635
952
  prng: snapshotPRNG(),
636
953
  });
637
954
 
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
955
  state.historyIndex = state.history.length - 1;
956
+ // Trim oldest entries if over the limit
957
+ trimHistory(state);
651
958
  state.visitCounts[passageName] =
652
959
  (state.visitCounts[passageName] ?? 0) + 1;
653
960
  state.renderCounts[passageName] =
@@ -657,7 +964,10 @@ export const useStoryStore = create<StoryState>()(
657
964
  // Watchers react to the completed transition (visit counts, cleared
658
965
  // temporaries). Like beforenavigate changes, their run actions belong
659
966
  // to the entered moment; navigations they request run afterwards.
660
- const enteredVars = get().variables;
967
+ enteredMoment = {
968
+ navigationId: get().navigationId,
969
+ variables: get().variables,
970
+ };
661
971
  navigationTriggerPhase = true;
662
972
  try {
663
973
  checkTriggersOnNavigation();
@@ -666,18 +976,7 @@ export const useStoryStore = create<StoryState>()(
666
976
  }
667
977
  const deferred = deferredNavigations;
668
978
  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;
979
+ finishEnteredMoment(get, set);
681
980
  persistSession(get);
682
981
 
683
982
  emit('afternavigate', passageName, previousPassage);
@@ -688,6 +987,7 @@ export const useStoryStore = create<StoryState>()(
688
987
  goBack: () => {
689
988
  const { historyIndex } = get();
690
989
  if (historyIndex <= 0) return;
990
+ finishEnteredMoment(get, set);
691
991
 
692
992
  const previousPassage = get().currentPassage;
693
993
  const targetPassage = get().history[historyIndex - 1]!.passage;
@@ -702,7 +1002,7 @@ export const useStoryStore = create<StoryState>()(
702
1002
  state.currentPassage = state.history[state.historyIndex]!.passage;
703
1003
  state.navigationId++;
704
1004
  state.variables = restoredVars;
705
- state.temporary = {};
1005
+ state.temporary = createNamespace();
706
1006
  });
707
1007
 
708
1008
  // Restored state is not a change watchers react to
@@ -717,6 +1017,7 @@ export const useStoryStore = create<StoryState>()(
717
1017
  goForward: () => {
718
1018
  const { historyIndex, history: hist } = get();
719
1019
  if (historyIndex >= hist.length - 1) return;
1020
+ finishEnteredMoment(get, set);
720
1021
 
721
1022
  const previousPassage = get().currentPassage;
722
1023
  const targetPassage = hist[historyIndex + 1]!.passage;
@@ -731,7 +1032,7 @@ export const useStoryStore = create<StoryState>()(
731
1032
  state.currentPassage = state.history[state.historyIndex]!.passage;
732
1033
  state.navigationId++;
733
1034
  state.variables = restoredVars;
734
- state.temporary = {};
1035
+ state.temporary = createNamespace();
735
1036
  });
736
1037
 
737
1038
  // Restored state is not a change watchers react to
@@ -745,36 +1046,42 @@ export const useStoryStore = create<StoryState>()(
745
1046
 
746
1047
  setVariable: (name: string, value: unknown) => {
747
1048
  set((state) => {
1049
+ checkVariableName(name, `$${name}`);
748
1050
  state.variables[name] = value;
749
1051
  });
750
1052
  },
751
1053
 
752
1054
  setTemporary: (name: string, value: unknown) => {
753
1055
  set((state) => {
1056
+ checkVariableName(name, `_${name}`);
754
1057
  state.temporary[name] = value;
755
1058
  });
756
1059
  },
757
1060
 
758
1061
  deleteVariable: (name: string) => {
759
1062
  set((state) => {
1063
+ checkVariableName(name, `$${name}`);
760
1064
  delete state.variables[name];
761
1065
  });
762
1066
  },
763
1067
 
764
1068
  deleteTemporary: (name: string) => {
765
1069
  set((state) => {
1070
+ checkVariableName(name, `_${name}`);
766
1071
  delete state.temporary[name];
767
1072
  });
768
1073
  },
769
1074
 
770
1075
  setTransient: (name: string, value: unknown) => {
771
1076
  set((state) => {
1077
+ checkVariableName(name, `%${name}`);
772
1078
  state.transient[name] = value;
773
1079
  });
774
1080
  },
775
1081
 
776
1082
  deleteTransient: (name: string) => {
777
1083
  set((state) => {
1084
+ checkVariableName(name, `%${name}`);
778
1085
  delete state.transient[name];
779
1086
  });
780
1087
  },
@@ -809,23 +1116,10 @@ export const useStoryStore = create<StoryState>()(
809
1116
 
810
1117
  // Switch to the new playthrough now, after beforerestart (whose saves
811
1118
  // 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
- );
1119
+ // issued from here on belongs to the new game.
1120
+ switchToNewPlaythrough(storyData.ifid);
1121
+ // A load from a slot issued before the restart must not apply
1122
+ replaceStateNow();
829
1123
 
830
1124
  const keepDeferred = get().renderDeferred;
831
1125
 
@@ -834,15 +1128,15 @@ export const useStoryStore = create<StoryState>()(
834
1128
 
835
1129
  resetPRNG();
836
1130
  resetTriggers();
837
- const initialVars = deepClone(variableDefaults);
1131
+ const initialVars = createNamespace(deepClone(variableDefaults));
838
1132
  resetModuleState(deepClone(initialVars));
839
1133
 
840
1134
  set((state) => {
841
1135
  state.currentPassage = startPassage.name;
842
1136
  state.navigationId++;
843
1137
  state.variables = initialVars;
844
- state.transient = deepClone(transientDefaults);
845
- state.temporary = {};
1138
+ state.transient = createNamespace(deepClone(transientDefaults));
1139
+ state.temporary = createNamespace();
846
1140
  state.history = [
847
1141
  {
848
1142
  passage: startPassage.name,
@@ -880,8 +1174,8 @@ export const useStoryStore = create<StoryState>()(
880
1174
  set((state) => {
881
1175
  state.saveError = null;
882
1176
  });
883
- const playthroughId = await playthrough;
884
- await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
1177
+ // Queued now, in call order with other storage operations
1178
+ await quickSave(storyData.ifid, playthrough, payload, slot, custom);
885
1179
  set((state) => {
886
1180
  state.knownSaves = {
887
1181
  ...state.knownSaves,
@@ -891,8 +1185,7 @@ export const useStoryStore = create<StoryState>()(
891
1185
  }).catch((err) => {
892
1186
  console.error('spindle: failed to save', err);
893
1187
  set((state) => {
894
- state.saveError =
895
- err instanceof Error ? err.message : 'Failed to save';
1188
+ state.saveError = errorMessage(err, 'Failed to save');
896
1189
  });
897
1190
  throw err;
898
1191
  }),
@@ -906,17 +1199,35 @@ export const useStoryStore = create<StoryState>()(
906
1199
  set((state) => {
907
1200
  state.loadError = null;
908
1201
  });
1202
+ // The game moves to the loaded save's playthrough in call order: the
1203
+ // read, queued now, makes it the stored current playthrough, and saves
1204
+ // issued after the load belong to it (a later restart or load moves
1205
+ // the game on, as usual). An empty slot leaves the playthrough as it
1206
+ // is.
1207
+ const previous = resolvePlaythroughId();
1208
+ const read = loadSlotSave(storyData.ifid, slot);
1209
+ const switched = switchToLookedUpPlaythrough(
1210
+ Promise.all([previous, read.catch(() => undefined)]).then(
1211
+ ([prev, loaded]) => loaded?.playthroughId || prev,
1212
+ ),
1213
+ );
1214
+ const replacement = ++stateReplacementsIssued;
909
1215
  return handled(
910
- loadQuickSave(storyData.ifid, slot)
911
- .then((payload) => {
912
- if (!payload) return;
913
- get().loadFromPayload(payload, slot);
1216
+ read
1217
+ .then(async (loaded) => {
1218
+ // The store names the loaded playthrough before the loaded
1219
+ // state is applied (and `afterload` fires)
1220
+ await switched;
1221
+ if (!loaded) return;
1222
+ // A restart, boot or direct load issued after this one won
1223
+ if (latestStateApplied > replacement) return;
1224
+ slotLoadApplying = replacement;
1225
+ get().loadFromPayload(loaded.payload, slot);
914
1226
  })
915
1227
  .catch((err) => {
916
1228
  console.error('spindle: failed to load save', err);
917
1229
  set((state) => {
918
- state.loadError =
919
- err instanceof Error ? err.message : 'Failed to load';
1230
+ state.loadError = errorMessage(err, 'Failed to load');
920
1231
  });
921
1232
  throw err;
922
1233
  }),
@@ -982,50 +1293,83 @@ export const useStoryStore = create<StoryState>()(
982
1293
 
983
1294
  clearGameData: () => {
984
1295
  const { storyData } = get();
985
- if (!storyData) return;
1296
+ if (!storyData) return Promise.resolve();
986
1297
 
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);
1298
+ // Queue the clearing, then restart now: the new playthrough is stored
1299
+ // after it, and operations issued from here on belong to the new game.
1300
+ // The slot cache empties once the clearing is done, after operations
1301
+ // issued before it have updated it.
1302
+ const cleared = smClearGameData(storyData.ifid).then(() => {
1303
+ set((state) => {
1304
+ state.knownSaves = {};
996
1305
  });
1306
+ });
1307
+ get().restart();
1308
+ return handled(
1309
+ cleared.catch((err) => {
1310
+ console.error('spindle: failed to clear game data', err);
1311
+ throw err;
1312
+ }),
1313
+ );
997
1314
  },
998
1315
 
999
1316
  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);
1317
+ // As clearGameData: queue the clearing, then restart now
1318
+ const cleared = smClearAllData().then(() => {
1319
+ set((state) => {
1320
+ state.knownSaves = {};
1012
1321
  });
1322
+ });
1323
+ get().restart();
1324
+ return handled(
1325
+ cleared.catch((err) => {
1326
+ console.error('spindle: failed to clear all data', err);
1327
+ throw err;
1328
+ }),
1329
+ );
1013
1330
  },
1014
1331
 
1015
1332
  deletePlaythrough: (playthroughId: string) => {
1016
1333
  const { storyData } = get();
1017
- if (!storyData) return;
1334
+ if (!storyData) return Promise.resolve();
1018
1335
 
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
- });
1336
+ // The running game can't go on in a deleted playthrough: its later
1337
+ // saves would belong to no playthrough. It moves to a new one, as on
1338
+ // restart but keeping its state. While the game's playthrough is not
1339
+ // known yet (init is looking it up, or a load from a slot is reading
1340
+ // the save that decides it), the deletion checks the one established.
1341
+ const ifid = storyData.ifid;
1342
+ const current = knownPlaythroughId();
1343
+ const established = playthroughSetup;
1344
+ const replacementId = crypto.randomUUID();
1345
+ const deletion = smDeletePlaythroughData(ifid, playthroughId, {
1346
+ current: current || established,
1347
+ id: replacementId,
1348
+ });
1349
+ if (playthroughId !== '' && playthroughId === current) {
1350
+ switchToPlaythrough(replacementId, deletion);
1351
+ } else if (current === '') {
1352
+ switchToLookedUpPlaythrough(
1353
+ deletion.then(
1354
+ (replaced) => (replaced ? replacementId : established),
1355
+ () => established,
1356
+ ),
1357
+ );
1358
+ }
1359
+
1360
+ return handled(
1361
+ deletion
1362
+ .then(async () => {
1363
+ const known = await populateKnownSaves(storyData.ifid);
1364
+ set((state) => {
1365
+ state.knownSaves = known;
1366
+ });
1367
+ })
1368
+ .catch((err) => {
1369
+ console.error('spindle: failed to delete playthrough', err);
1370
+ throw err;
1371
+ }),
1372
+ );
1029
1373
  },
1030
1374
 
1031
1375
  getSavePayload: (): SavePayload => {
@@ -1047,7 +1391,7 @@ export const useStoryStore = create<StoryState>()(
1047
1391
  }
1048
1392
  saveHistory.push({
1049
1393
  passage: history[i]!.passage,
1050
- variables: deepClone(vars),
1394
+ variables: plainCopy(vars),
1051
1395
  timestamp: history[i]!.timestamp,
1052
1396
  prng: history[i]!.prng,
1053
1397
  });
@@ -1055,7 +1399,7 @@ export const useStoryStore = create<StoryState>()(
1055
1399
 
1056
1400
  return {
1057
1401
  passage: currentPassage,
1058
- variables: deepClone(variables),
1402
+ variables: plainCopy(variables),
1059
1403
  history: saveHistory,
1060
1404
  historyIndex,
1061
1405
  visitCounts: { ...visitCounts },
@@ -1073,14 +1417,29 @@ export const useStoryStore = create<StoryState>()(
1073
1417
  };
1074
1418
  },
1075
1419
 
1076
- loadFromPayload: (payload: SavePayload, slot?: string) => {
1420
+ loadFromPayload: (
1421
+ payload: SavePayload,
1422
+ slot?: string,
1423
+ playthroughId?: string,
1424
+ ) => {
1425
+ const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
1426
+ slotLoadApplying = null;
1077
1427
  if (payload.history.length === 0) {
1078
1428
  console.warn('loadFromPayload: rejecting payload with empty history');
1079
1429
  return;
1080
1430
  }
1431
+ latestStateApplied = replacement;
1081
1432
 
1082
1433
  emit('beforeload', slot);
1083
1434
 
1435
+ // Loading a save moves the game to the save's playthrough, after the
1436
+ // `beforeload` handlers (whose saves belong to the game being left).
1437
+ // Restoring the session passes none: the game stays in its playthrough.
1438
+ const ifid = get().storyData?.ifid;
1439
+ if (playthroughId && ifid) {
1440
+ switchToLoadedPlaythrough(ifid, playthroughId);
1441
+ }
1442
+
1084
1443
  // Restore the state on entering the saved passage, not the payload's
1085
1444
  // live variables: the passage remounts and runs its {set}/{do} again,
1086
1445
  // so restoring their results as well would apply them twice. Changes
@@ -1091,12 +1450,16 @@ export const useStoryStore = create<StoryState>()(
1091
1450
  // The payload is already live (deserialized at the storage boundary by
1092
1451
  // loadSave/loadSession); deserializing again would corrupt built-ins.
1093
1452
  // Convert full snapshots to patch entries
1094
- const base = deepClone(payload.history[0]?.variables ?? {});
1453
+ const base = createNamespace(
1454
+ deepClone(payload.history[0]?.variables ?? {}),
1455
+ );
1095
1456
  const newPatchEntries: PatchEntry[] = [];
1096
1457
 
1097
1458
  let prevVars: Record<string, unknown> = base;
1098
1459
  for (let i = 1; i < payload.history.length; i++) {
1099
- const currVars = deepClone(payload.history[i]!.variables);
1460
+ const currVars = createNamespace(
1461
+ deepClone(payload.history[i]!.variables),
1462
+ );
1100
1463
  newPatchEntries.push(computeVarPatches(prevVars, currVars));
1101
1464
  prevVars = currVars;
1102
1465
  }
@@ -1115,7 +1478,9 @@ export const useStoryStore = create<StoryState>()(
1115
1478
  set((state) => {
1116
1479
  state.currentPassage = payload.passage;
1117
1480
  state.navigationId++;
1118
- state.variables = deepClone(entry?.variables ?? payload.variables);
1481
+ state.variables = createNamespace(
1482
+ deepClone(entry?.variables ?? payload.variables),
1483
+ );
1119
1484
  state.history = payload.history.map((m) => ({
1120
1485
  passage: m.passage,
1121
1486
  timestamp: m.timestamp,
@@ -1125,10 +1490,12 @@ export const useStoryStore = create<StoryState>()(
1125
1490
  0,
1126
1491
  Math.min(payload.historyIndex, state.history.length - 1),
1127
1492
  );
1493
+ // A save made under a higher limit keeps no more than the limit
1494
+ trimHistory(state);
1128
1495
  state.visitCounts = payload.visitCounts ?? {};
1129
1496
  state.renderCounts = payload.renderCounts ?? {};
1130
- state.temporary = {};
1131
- state.transient = deepClone(get().transientDefaults);
1497
+ state.temporary = createNamespace();
1498
+ state.transient = createNamespace(deepClone(get().transientDefaults));
1132
1499
  });
1133
1500
 
1134
1501
  // Loaded state is not a change watchers react to