@rohal12/spindle 0.51.3 → 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 (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  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 +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  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 +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. 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;
@@ -234,6 +329,144 @@ export function recordStoryInitState(): void {
234
329
  });
235
330
  }
236
331
 
332
+ // ---------------------------------------------------------------------------
333
+ // Playthrough setup
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
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.
345
+ */
346
+ let playthroughSetup: Promise<string> = Promise.resolve('');
347
+
348
+ /**
349
+ * Bumped by every playthrough switch; a switch that settles after a later
350
+ * one was issued must not set its ID.
351
+ */
352
+ let playthroughGeneration = 0;
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
+
369
+ /**
370
+ * The playthrough a save issued now belongs to, once its record is stored.
371
+ * Read synchronously at the call: restart() switches the store's
372
+ * `playthroughId` at once, so a save issued after it (even before the new
373
+ * playthrough is stored) belongs to the new playthrough, and a later restart
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.
377
+ */
378
+ export function resolvePlaythroughId(): Promise<string> {
379
+ const current = knownPlaythroughId();
380
+ return playthroughSetup.then((established) => current || established);
381
+ }
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
+
237
470
  // ---------------------------------------------------------------------------
238
471
  // Runtime handler cleanup (auto-unsub on restart)
239
472
  // ---------------------------------------------------------------------------
@@ -364,6 +597,12 @@ export interface StoryState {
364
597
  visitCounts: Record<string, number>;
365
598
  renderCounts: Record<string, number>;
366
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
+ */
367
606
  playthroughId: string;
368
607
  maxHistory: number;
369
608
  quickSaveKey: string | null;
@@ -400,6 +639,12 @@ export interface StoryState {
400
639
  trackRender: (passageName: string) => void;
401
640
  restart: () => void;
402
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
+ */
403
648
  load: (slot?: string) => Promise<void>;
404
649
  hasSave: (slot?: string) => boolean;
405
650
  getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
@@ -407,9 +652,18 @@ export interface StoryState {
407
652
  deleteSave: (slot?: string) => Promise<void>;
408
653
  exportSave: (slot?: string) => Promise<SaveExport | null>;
409
654
  importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
410
- clearGameData: () => void;
411
- clearAllData: () => void;
412
- 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>;
413
667
  getSavePayload: () => SavePayload;
414
668
  /**
415
669
  * Start capturing a save, before the `beforesave` hooks run. The returned
@@ -419,9 +673,15 @@ export interface StoryState {
419
673
  beginSave: () => () => SavePayload;
420
674
  /**
421
675
  * Replace the game state with a live (deserialized) payload. `slot` is
422
- * 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.
423
679
  */
424
- loadFromPayload: (payload: SavePayload, slot?: string) => void;
680
+ loadFromPayload: (
681
+ payload: SavePayload,
682
+ slot?: string,
683
+ playthroughId?: string,
684
+ ) => void;
425
685
  getHistoryVariables: (index: number) => Record<string, unknown>;
426
686
  setTransition: (config: TransitionConfig | null) => void;
427
687
  setNextTransition: (config: TransitionConfig | null) => void;
@@ -430,6 +690,94 @@ export interface StoryState {
430
690
  clearDeferredRender: () => void;
431
691
  }
432
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
+
433
781
  /**
434
782
  * Return `p` marked as handled: a caller that ignores the result of a
435
783
  * fire-and-forget operation (whose failure is already logged) gets no
@@ -442,15 +790,15 @@ function handled<T>(p: Promise<T>): Promise<T> {
442
790
  }
443
791
 
444
792
  export const useStoryStore = create<StoryState>()(
445
- immer((set, get) => ({
793
+ storyStore((set, get) => ({
446
794
  storyData: null,
447
795
  currentPassage: '',
448
796
  navigationId: 0,
449
- variables: {},
797
+ variables: createNamespace(),
450
798
  variableDefaults: {},
451
- transient: {},
799
+ transient: createNamespace(),
452
800
  transientDefaults: {},
453
- temporary: {},
801
+ temporary: createNamespace(),
454
802
  history: [],
455
803
  historyIndex: -1,
456
804
  visitCounts: {},
@@ -468,9 +816,13 @@ export const useStoryStore = create<StoryState>()(
468
816
  renderDeferred: false,
469
817
 
470
818
  setMaxHistory: (limit: number) => {
819
+ let trimmed = false;
471
820
  set((state) => {
472
821
  state.maxHistory = Math.max(1, Math.round(limit));
822
+ // A lower limit takes effect at once
823
+ trimmed = trimHistory(state);
473
824
  });
825
+ if (trimmed) persistSession(get);
474
826
  },
475
827
 
476
828
  setQuickSaveKey: (key: string | null) => {
@@ -497,18 +849,20 @@ export const useStoryStore = create<StoryState>()(
497
849
  );
498
850
  }
499
851
 
500
- const initialVars = deepClone(variableDefaults);
852
+ const initialVars = createNamespace(deepClone(variableDefaults));
501
853
  resetModuleState(deepClone(initialVars));
502
854
 
503
855
  set((state) => {
504
856
  state.storyData = storyData as StoryData;
857
+ // Unknown until the save system has looked it up (below)
858
+ state.playthroughId = '';
505
859
  state.currentPassage = startPassage.name;
506
860
  state.navigationId++;
507
861
  state.variables = initialVars;
508
862
  state.variableDefaults = variableDefaults;
509
- state.transient = deepClone(transientDefaults);
863
+ state.transient = createNamespace(deepClone(transientDefaults));
510
864
  state.transientDefaults = transientDefaults;
511
- state.temporary = {};
865
+ state.temporary = createNamespace();
512
866
  state.history = [
513
867
  {
514
868
  passage: startPassage.name,
@@ -523,33 +877,29 @@ export const useStoryStore = create<StoryState>()(
523
877
  // Update lastNavigationVars to the Immer-produced reference
524
878
  lastNavigationVars = get().variables;
525
879
 
526
- // Init save system (fire-and-forget — DB will be ready before user opens dialog)
527
- const ifid = storyData.ifid;
528
- initSaveSystem()
529
- .then(async () => {
530
- const existingId = await getCurrentPlaythroughId(ifid);
531
- if (existingId) {
532
- set((state) => {
533
- state.playthroughId = existingId;
534
- });
535
- } else {
536
- const newId = await startNewPlaythrough(ifid);
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.
537
893
  set((state) => {
538
- state.playthroughId = newId;
894
+ state.knownSaves = knownSaves;
539
895
  });
540
- }
541
-
542
- // Populate knownSaves from IDB so hasSave() works after reload
543
- const saves = await populateKnownSaves(ifid);
544
- if (Object.keys(saves).length > 0) {
545
- set((state) => {
546
- state.knownSaves = saves;
547
- });
548
- }
549
- })
550
- .catch((err) =>
551
- console.error('spindle: failed to init save system', err),
552
- );
896
+ return id;
897
+ })
898
+ .catch((err) => {
899
+ console.error('spindle: failed to init save system', err);
900
+ return '';
901
+ }),
902
+ );
553
903
  },
554
904
 
555
905
  navigate: (passageName: string) => {
@@ -583,7 +933,7 @@ export const useStoryStore = create<StoryState>()(
583
933
  const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
584
934
 
585
935
  set((state) => {
586
- state.temporary = {};
936
+ state.temporary = createNamespace();
587
937
  state.currentPassage = passageName;
588
938
  state.navigationId++;
589
939
 
@@ -602,19 +952,9 @@ export const useStoryStore = create<StoryState>()(
602
952
  prng: snapshotPRNG(),
603
953
  });
604
954
 
605
- // Trim oldest entries if over the limit
606
- const overflow = state.history.length - state.maxHistory;
607
- if (overflow > 0) {
608
- // Advance base through trimmed transitions
609
- for (let i = 0; i < overflow; i++) {
610
- variableBase = applyPatches(variableBase, patchEntries[i]!.forward);
611
- }
612
- state.history = state.history.slice(overflow);
613
- patchEntries = patchEntries.slice(overflow);
614
- serializedHistory = serializedHistory.slice(overflow);
615
- }
616
-
617
955
  state.historyIndex = state.history.length - 1;
956
+ // Trim oldest entries if over the limit
957
+ trimHistory(state);
618
958
  state.visitCounts[passageName] =
619
959
  (state.visitCounts[passageName] ?? 0) + 1;
620
960
  state.renderCounts[passageName] =
@@ -624,7 +964,10 @@ export const useStoryStore = create<StoryState>()(
624
964
  // Watchers react to the completed transition (visit counts, cleared
625
965
  // temporaries). Like beforenavigate changes, their run actions belong
626
966
  // to the entered moment; navigations they request run afterwards.
627
- const enteredVars = get().variables;
967
+ enteredMoment = {
968
+ navigationId: get().navigationId,
969
+ variables: get().variables,
970
+ };
628
971
  navigationTriggerPhase = true;
629
972
  try {
630
973
  checkTriggersOnNavigation();
@@ -633,18 +976,7 @@ export const useStoryStore = create<StoryState>()(
633
976
  }
634
977
  const deferred = deferredNavigations;
635
978
  deferredNavigations = [];
636
- if (get().variables !== enteredVars) {
637
- rerecordNewestMoment(get().variables);
638
- }
639
- const prng = snapshotPRNG();
640
- const recorded = get().history[get().historyIndex]!.prng;
641
- if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) {
642
- set((state) => {
643
- state.history[state.historyIndex]!.prng = prng;
644
- });
645
- }
646
-
647
- lastNavigationVars = get().variables;
979
+ finishEnteredMoment(get, set);
648
980
  persistSession(get);
649
981
 
650
982
  emit('afternavigate', passageName, previousPassage);
@@ -655,6 +987,7 @@ export const useStoryStore = create<StoryState>()(
655
987
  goBack: () => {
656
988
  const { historyIndex } = get();
657
989
  if (historyIndex <= 0) return;
990
+ finishEnteredMoment(get, set);
658
991
 
659
992
  const previousPassage = get().currentPassage;
660
993
  const targetPassage = get().history[historyIndex - 1]!.passage;
@@ -669,7 +1002,7 @@ export const useStoryStore = create<StoryState>()(
669
1002
  state.currentPassage = state.history[state.historyIndex]!.passage;
670
1003
  state.navigationId++;
671
1004
  state.variables = restoredVars;
672
- state.temporary = {};
1005
+ state.temporary = createNamespace();
673
1006
  });
674
1007
 
675
1008
  // Restored state is not a change watchers react to
@@ -684,6 +1017,7 @@ export const useStoryStore = create<StoryState>()(
684
1017
  goForward: () => {
685
1018
  const { historyIndex, history: hist } = get();
686
1019
  if (historyIndex >= hist.length - 1) return;
1020
+ finishEnteredMoment(get, set);
687
1021
 
688
1022
  const previousPassage = get().currentPassage;
689
1023
  const targetPassage = hist[historyIndex + 1]!.passage;
@@ -698,7 +1032,7 @@ export const useStoryStore = create<StoryState>()(
698
1032
  state.currentPassage = state.history[state.historyIndex]!.passage;
699
1033
  state.navigationId++;
700
1034
  state.variables = restoredVars;
701
- state.temporary = {};
1035
+ state.temporary = createNamespace();
702
1036
  });
703
1037
 
704
1038
  // Restored state is not a change watchers react to
@@ -712,36 +1046,42 @@ export const useStoryStore = create<StoryState>()(
712
1046
 
713
1047
  setVariable: (name: string, value: unknown) => {
714
1048
  set((state) => {
1049
+ checkVariableName(name, `$${name}`);
715
1050
  state.variables[name] = value;
716
1051
  });
717
1052
  },
718
1053
 
719
1054
  setTemporary: (name: string, value: unknown) => {
720
1055
  set((state) => {
1056
+ checkVariableName(name, `_${name}`);
721
1057
  state.temporary[name] = value;
722
1058
  });
723
1059
  },
724
1060
 
725
1061
  deleteVariable: (name: string) => {
726
1062
  set((state) => {
1063
+ checkVariableName(name, `$${name}`);
727
1064
  delete state.variables[name];
728
1065
  });
729
1066
  },
730
1067
 
731
1068
  deleteTemporary: (name: string) => {
732
1069
  set((state) => {
1070
+ checkVariableName(name, `_${name}`);
733
1071
  delete state.temporary[name];
734
1072
  });
735
1073
  },
736
1074
 
737
1075
  setTransient: (name: string, value: unknown) => {
738
1076
  set((state) => {
1077
+ checkVariableName(name, `%${name}`);
739
1078
  state.transient[name] = value;
740
1079
  });
741
1080
  },
742
1081
 
743
1082
  deleteTransient: (name: string) => {
744
1083
  set((state) => {
1084
+ checkVariableName(name, `%${name}`);
745
1085
  delete state.transient[name];
746
1086
  });
747
1087
  },
@@ -774,6 +1114,13 @@ export const useStoryStore = create<StoryState>()(
774
1114
 
775
1115
  emit('beforerestart');
776
1116
 
1117
+ // Switch to the new playthrough now, after beforerestart (whose saves
1118
+ // belong to the game being left) and before StoryInit, so every save
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();
1123
+
777
1124
  const keepDeferred = get().renderDeferred;
778
1125
 
779
1126
  // Clean up all runtime-phase handlers (after beforerestart has fired)
@@ -781,15 +1128,15 @@ export const useStoryStore = create<StoryState>()(
781
1128
 
782
1129
  resetPRNG();
783
1130
  resetTriggers();
784
- const initialVars = deepClone(variableDefaults);
1131
+ const initialVars = createNamespace(deepClone(variableDefaults));
785
1132
  resetModuleState(deepClone(initialVars));
786
1133
 
787
1134
  set((state) => {
788
1135
  state.currentPassage = startPassage.name;
789
1136
  state.navigationId++;
790
1137
  state.variables = initialVars;
791
- state.transient = deepClone(transientDefaults);
792
- state.temporary = {};
1138
+ state.transient = createNamespace(deepClone(transientDefaults));
1139
+ state.temporary = createNamespace();
793
1140
  state.history = [
794
1141
  {
795
1142
  passage: startPassage.name,
@@ -814,29 +1161,21 @@ export const useStoryStore = create<StoryState>()(
814
1161
  emit('storyinit');
815
1162
  // The storyinit handlers' changes belong to the start moment too
816
1163
  recordStoryInitState();
817
-
818
- // Start a new playthrough on restart
819
- startNewPlaythrough(storyData.ifid)
820
- .then((newId) => {
821
- set((state) => {
822
- state.playthroughId = newId;
823
- });
824
- })
825
- .catch((err) =>
826
- console.error('spindle: failed to start new playthrough', err),
827
- );
828
1164
  },
829
1165
 
830
1166
  save: (slot?: string, custom?: Record<string, unknown>) => {
831
- const { storyData, playthroughId } = get();
1167
+ const { storyData } = get();
832
1168
  if (!storyData) return Promise.resolve();
1169
+ // The playthrough current now, not when the write runs
1170
+ const playthrough = resolvePlaythroughId();
833
1171
 
834
1172
  return handled(
835
1173
  saveWithHooks(slot, custom, get().beginSave, async (payload) => {
836
1174
  set((state) => {
837
1175
  state.saveError = null;
838
1176
  });
839
- 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);
840
1179
  set((state) => {
841
1180
  state.knownSaves = {
842
1181
  ...state.knownSaves,
@@ -846,8 +1185,7 @@ export const useStoryStore = create<StoryState>()(
846
1185
  }).catch((err) => {
847
1186
  console.error('spindle: failed to save', err);
848
1187
  set((state) => {
849
- state.saveError =
850
- err instanceof Error ? err.message : 'Failed to save';
1188
+ state.saveError = errorMessage(err, 'Failed to save');
851
1189
  });
852
1190
  throw err;
853
1191
  }),
@@ -861,17 +1199,35 @@ export const useStoryStore = create<StoryState>()(
861
1199
  set((state) => {
862
1200
  state.loadError = null;
863
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;
864
1215
  return handled(
865
- loadQuickSave(storyData.ifid, slot)
866
- .then((payload) => {
867
- if (!payload) return;
868
- 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);
869
1226
  })
870
1227
  .catch((err) => {
871
1228
  console.error('spindle: failed to load save', err);
872
1229
  set((state) => {
873
- state.loadError =
874
- err instanceof Error ? err.message : 'Failed to load';
1230
+ state.loadError = errorMessage(err, 'Failed to load');
875
1231
  });
876
1232
  throw err;
877
1233
  }),
@@ -937,50 +1293,83 @@ export const useStoryStore = create<StoryState>()(
937
1293
 
938
1294
  clearGameData: () => {
939
1295
  const { storyData } = get();
940
- if (!storyData) return;
1296
+ if (!storyData) return Promise.resolve();
941
1297
 
942
- smClearGameData(storyData.ifid)
943
- .then(() => {
944
- set((state) => {
945
- state.knownSaves = {};
946
- });
947
- get().restart();
948
- })
949
- .catch((err) => {
950
- 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 = {};
951
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
+ );
952
1314
  },
953
1315
 
954
1316
  clearAllData: () => {
955
- const { storyData } = get();
956
- if (!storyData) return;
957
-
958
- smClearAllData()
959
- .then(() => {
960
- set((state) => {
961
- state.knownSaves = {};
962
- });
963
- get().restart();
964
- })
965
- .catch((err) => {
966
- 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 = {};
967
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
+ );
968
1330
  },
969
1331
 
970
1332
  deletePlaythrough: (playthroughId: string) => {
971
1333
  const { storyData } = get();
972
- if (!storyData) return;
1334
+ if (!storyData) return Promise.resolve();
973
1335
 
974
- smDeletePlaythroughData(storyData.ifid, playthroughId)
975
- .then(async () => {
976
- const known = await populateKnownSaves(storyData.ifid);
977
- set((state) => {
978
- state.knownSaves = known;
979
- });
980
- })
981
- .catch((err) => {
982
- console.error('spindle: failed to delete playthrough', err);
983
- });
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
+ );
984
1373
  },
985
1374
 
986
1375
  getSavePayload: (): SavePayload => {
@@ -1002,7 +1391,7 @@ export const useStoryStore = create<StoryState>()(
1002
1391
  }
1003
1392
  saveHistory.push({
1004
1393
  passage: history[i]!.passage,
1005
- variables: deepClone(vars),
1394
+ variables: plainCopy(vars),
1006
1395
  timestamp: history[i]!.timestamp,
1007
1396
  prng: history[i]!.prng,
1008
1397
  });
@@ -1010,7 +1399,7 @@ export const useStoryStore = create<StoryState>()(
1010
1399
 
1011
1400
  return {
1012
1401
  passage: currentPassage,
1013
- variables: deepClone(variables),
1402
+ variables: plainCopy(variables),
1014
1403
  history: saveHistory,
1015
1404
  historyIndex,
1016
1405
  visitCounts: { ...visitCounts },
@@ -1028,14 +1417,29 @@ export const useStoryStore = create<StoryState>()(
1028
1417
  };
1029
1418
  },
1030
1419
 
1031
- 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;
1032
1427
  if (payload.history.length === 0) {
1033
1428
  console.warn('loadFromPayload: rejecting payload with empty history');
1034
1429
  return;
1035
1430
  }
1431
+ latestStateApplied = replacement;
1036
1432
 
1037
1433
  emit('beforeload', slot);
1038
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
+
1039
1443
  // Restore the state on entering the saved passage, not the payload's
1040
1444
  // live variables: the passage remounts and runs its {set}/{do} again,
1041
1445
  // so restoring their results as well would apply them twice. Changes
@@ -1046,12 +1450,16 @@ export const useStoryStore = create<StoryState>()(
1046
1450
  // The payload is already live (deserialized at the storage boundary by
1047
1451
  // loadSave/loadSession); deserializing again would corrupt built-ins.
1048
1452
  // Convert full snapshots to patch entries
1049
- const base = deepClone(payload.history[0]?.variables ?? {});
1453
+ const base = createNamespace(
1454
+ deepClone(payload.history[0]?.variables ?? {}),
1455
+ );
1050
1456
  const newPatchEntries: PatchEntry[] = [];
1051
1457
 
1052
1458
  let prevVars: Record<string, unknown> = base;
1053
1459
  for (let i = 1; i < payload.history.length; i++) {
1054
- const currVars = deepClone(payload.history[i]!.variables);
1460
+ const currVars = createNamespace(
1461
+ deepClone(payload.history[i]!.variables),
1462
+ );
1055
1463
  newPatchEntries.push(computeVarPatches(prevVars, currVars));
1056
1464
  prevVars = currVars;
1057
1465
  }
@@ -1070,7 +1478,9 @@ export const useStoryStore = create<StoryState>()(
1070
1478
  set((state) => {
1071
1479
  state.currentPassage = payload.passage;
1072
1480
  state.navigationId++;
1073
- state.variables = deepClone(entry?.variables ?? payload.variables);
1481
+ state.variables = createNamespace(
1482
+ deepClone(entry?.variables ?? payload.variables),
1483
+ );
1074
1484
  state.history = payload.history.map((m) => ({
1075
1485
  passage: m.passage,
1076
1486
  timestamp: m.timestamp,
@@ -1080,10 +1490,12 @@ export const useStoryStore = create<StoryState>()(
1080
1490
  0,
1081
1491
  Math.min(payload.historyIndex, state.history.length - 1),
1082
1492
  );
1493
+ // A save made under a higher limit keeps no more than the limit
1494
+ trimHistory(state);
1083
1495
  state.visitCounts = payload.visitCounts ?? {};
1084
1496
  state.renderCounts = payload.renderCounts ?? {};
1085
- state.temporary = {};
1086
- state.transient = deepClone(get().transientDefaults);
1497
+ state.temporary = createNamespace();
1498
+ state.transient = createNamespace(deepClone(get().transientDefaults));
1087
1499
  });
1088
1500
 
1089
1501
  // Loaded state is not a change watchers react to