@rohal12/spindle 0.52.0 → 0.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts CHANGED
@@ -46,15 +46,17 @@ import {
46
46
  clearAllData as smClearAllData,
47
47
  deletePlaythroughData as smDeletePlaythroughData,
48
48
  } from './saves/save-manager';
49
- import { deepClone, serialize } from './class-registry';
49
+ import { deepClone, deepEqual, serialize } from './class-registry';
50
50
  import {
51
51
  snapshotPRNG,
52
52
  restorePRNG,
53
53
  resetPRNG,
54
+ withoutDraws,
54
55
  type PRNGSnapshot,
55
56
  } from './prng';
56
57
  import { errorMessage } from './utils/error-message';
57
58
  import {
59
+ isMergeable,
58
60
  routeStoreUpdate,
59
61
  runWithCommittedMutations,
60
62
  } from './execute-mutation';
@@ -64,6 +66,7 @@ import {
64
66
  isNamespace,
65
67
  type Namespace,
66
68
  } from './utils/namespace';
69
+ import { createCounts, type Counts } from './utils/counts';
67
70
 
68
71
  enablePatches();
69
72
  // Story state holds Map and Set values: Immer must be able to draft them
@@ -531,13 +534,14 @@ function loadedEntryMoment(
531
534
  }
532
535
 
533
536
  /**
534
- * Copy the variables that changed between `before` and `after` (the live
535
- * variables around the `beforesave` hooks) into the payload's snapshot of the
536
- * saved moment. A load restores that snapshot, the state on entering the
537
- * passage, and runs the passage again, so data a hook adds to a save would
538
- * otherwise be lost on load (#227). Only the payload's copy changes: the live
539
- * history keeps the recorded snapshot (#159). Store updates are immutable, so
540
- * 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).
541
545
  */
542
546
  function keepHookWrites(
543
547
  payload: SavePayload,
@@ -546,15 +550,100 @@ function keepHookWrites(
546
550
  ): void {
547
551
  const moment = payload.history[payload.historyIndex];
548
552
  if (!moment) return;
549
- const has = (vars: Record<string, unknown>, key: string) =>
550
- Object.prototype.hasOwnProperty.call(vars, key);
551
- for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
552
- if (!has(after, key)) {
553
- delete moment.variables[key];
554
- } else if (!has(before, key) || after[key] !== before[key]) {
555
- 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
+ }
556
644
  }
557
645
  }
646
+ ancestors.delete(after);
558
647
  }
559
648
 
560
649
  /** Restore or reset PRNG from a history moment's snapshot. */
@@ -594,8 +683,8 @@ export interface StoryState {
594
683
  temporary: Record<string, unknown>;
595
684
  history: HistoryMoment[];
596
685
  historyIndex: number;
597
- visitCounts: Record<string, number>;
598
- renderCounts: Record<string, number>;
686
+ visitCounts: Counts;
687
+ renderCounts: Counts;
599
688
  knownSaves: Record<string, true>;
600
689
  /**
601
690
  * The playthrough the running game is in: its saves are grouped under it.
@@ -801,8 +890,8 @@ export const useStoryStore = create<StoryState>()(
801
890
  temporary: createNamespace(),
802
891
  history: [],
803
892
  historyIndex: -1,
804
- visitCounts: {},
805
- renderCounts: {},
893
+ visitCounts: createCounts(),
894
+ renderCounts: createCounts(),
806
895
  knownSaves: {},
807
896
  playthroughId: '',
808
897
  maxHistory: 40,
@@ -870,8 +959,8 @@ export const useStoryStore = create<StoryState>()(
870
959
  },
871
960
  ];
872
961
  state.historyIndex = 0;
873
- state.visitCounts = { [startPassage.name]: 1 };
874
- state.renderCounts = { [startPassage.name]: 1 };
962
+ state.visitCounts = createCounts(null, startPassage.name);
963
+ state.renderCounts = createCounts(null, startPassage.name);
875
964
  });
876
965
 
877
966
  // Update lastNavigationVars to the Immer-produced reference
@@ -955,10 +1044,8 @@ export const useStoryStore = create<StoryState>()(
955
1044
  state.historyIndex = state.history.length - 1;
956
1045
  // Trim oldest entries if over the limit
957
1046
  trimHistory(state);
958
- state.visitCounts[passageName] =
959
- (state.visitCounts[passageName] ?? 0) + 1;
960
- state.renderCounts[passageName] =
961
- (state.renderCounts[passageName] ?? 0) + 1;
1047
+ state.visitCounts = createCounts(state.visitCounts, passageName);
1048
+ state.renderCounts = createCounts(state.renderCounts, passageName);
962
1049
  });
963
1050
 
964
1051
  // Watchers react to the completed transition (visit counts, cleared
@@ -1094,8 +1181,7 @@ export const useStoryStore = create<StoryState>()(
1094
1181
 
1095
1182
  trackRender: (passageName: string) => {
1096
1183
  set((state) => {
1097
- state.renderCounts[passageName] =
1098
- (state.renderCounts[passageName] ?? 0) + 1;
1184
+ state.renderCounts = createCounts(state.renderCounts, passageName);
1099
1185
  });
1100
1186
  },
1101
1187
 
@@ -1144,8 +1230,8 @@ export const useStoryStore = create<StoryState>()(
1144
1230
  },
1145
1231
  ];
1146
1232
  state.historyIndex = 0;
1147
- state.visitCounts = { [startPassage.name]: 1 };
1148
- state.renderCounts = { [startPassage.name]: 1 };
1233
+ state.visitCounts = createCounts(null, startPassage.name);
1234
+ state.renderCounts = createCounts(null, startPassage.name);
1149
1235
  if (!keepDeferred) {
1150
1236
  state.renderDeferred = false;
1151
1237
  }
@@ -1492,8 +1578,8 @@ export const useStoryStore = create<StoryState>()(
1492
1578
  );
1493
1579
  // A save made under a higher limit keeps no more than the limit
1494
1580
  trimHistory(state);
1495
- state.visitCounts = payload.visitCounts ?? {};
1496
- state.renderCounts = payload.renderCounts ?? {};
1581
+ state.visitCounts = createCounts(payload.visitCounts);
1582
+ state.renderCounts = createCounts(payload.renderCounts);
1497
1583
  state.temporary = createNamespace();
1498
1584
  state.transient = createNamespace(deepClone(get().transientDefaults));
1499
1585
  });
@@ -1517,7 +1603,8 @@ export const useStoryStore = create<StoryState>()(
1517
1603
  // Write the loaded game to the session so a refresh restores it
1518
1604
  persistSession(get);
1519
1605
 
1520
- emit('afterload', slot);
1606
+ // Draws made by the handlers would shift the passage's replayed rolls
1607
+ withoutDraws(() => emit('afterload', slot));
1521
1608
  },
1522
1609
 
1523
1610
  getHistoryVariables: (index: number): Record<string, unknown> => {
package/src/story-api.ts CHANGED
@@ -24,6 +24,7 @@ import { registerClass } from './class-registry';
24
24
  import { frozenCopy, getActiveMutationScope } from './execute-mutation';
25
25
  import { getByPath, setByPath } from './utils/object-path';
26
26
  import { checkVariableName } from './utils/namespace';
27
+ import { countOf } from './utils/counts';
27
28
  import { defineMacro } from './define-macro';
28
29
  import type { MacroDefinition } from './define-macro';
29
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -358,7 +359,7 @@ function createStoryAPI(): StoryAPI {
358
359
 
359
360
  visited(name?: string): number {
360
361
  const state = useStoryStore.getState();
361
- return state.visitCounts[name ?? state.currentPassage] ?? 0;
362
+ return countOf(state.visitCounts, name ?? state.currentPassage);
362
363
  },
363
364
 
364
365
  hasVisited(name?: string): boolean {
@@ -367,17 +368,17 @@ function createStoryAPI(): StoryAPI {
367
368
 
368
369
  hasVisitedAny(...names: string[]): boolean {
369
370
  const { visitCounts } = useStoryStore.getState();
370
- return names.some((n) => (visitCounts[n] ?? 0) > 0);
371
+ return names.some((n) => countOf(visitCounts, n) > 0);
371
372
  },
372
373
 
373
374
  hasVisitedAll(...names: string[]): boolean {
374
375
  const { visitCounts } = useStoryStore.getState();
375
- return names.every((n) => (visitCounts[n] ?? 0) > 0);
376
+ return names.every((n) => countOf(visitCounts, n) > 0);
376
377
  },
377
378
 
378
379
  rendered(name?: string): number {
379
380
  const state = useStoryStore.getState();
380
- return state.renderCounts[name ?? state.currentPassage] ?? 0;
381
+ return countOf(state.renderCounts, name ?? state.currentPassage);
381
382
  },
382
383
 
383
384
  hasRendered(name?: string): boolean {
@@ -386,12 +387,12 @@ function createStoryAPI(): StoryAPI {
386
387
 
387
388
  hasRenderedAny(...names: string[]): boolean {
388
389
  const { renderCounts } = useStoryStore.getState();
389
- return names.some((n) => (renderCounts[n] ?? 0) > 0);
390
+ return names.some((n) => countOf(renderCounts, n) > 0);
390
391
  },
391
392
 
392
393
  hasRenderedAll(...names: string[]): boolean {
393
394
  const { renderCounts } = useStoryStore.getState();
394
- return names.every((n) => (renderCounts[n] ?? 0) > 0);
395
+ return names.every((n) => countOf(renderCounts, n) > 0);
395
396
  },
396
397
 
397
398
  currentPassage(): Passage | undefined {
package/src/triggers.ts CHANGED
@@ -19,7 +19,6 @@ export interface QueuedDialog {
19
19
  }
20
20
 
21
21
  interface Trigger {
22
- id: number;
23
22
  name?: string;
24
23
  condition: string;
25
24
  callback?: () => void;
@@ -30,7 +29,6 @@ interface Trigger {
30
29
  macroKey?: string;
31
30
  }
32
31
 
33
- let nextId = 0;
34
32
  let triggers: Trigger[] = [];
35
33
  let checking = false;
36
34
 
@@ -67,13 +65,11 @@ function registerTrigger(
67
65
  callbackOrOptions: (() => void) | WatchOptions,
68
66
  macroKey?: string,
69
67
  ): () => void {
70
- const id = nextId++;
71
68
  const isCallback = typeof callbackOrOptions === 'function';
72
69
  const options = isCallback ? undefined : callbackOrOptions;
73
70
  const callback = isCallback ? callbackOrOptions : undefined;
74
71
 
75
72
  const trigger: Trigger = {
76
- id,
77
73
  name: options?.name,
78
74
  condition,
79
75
  callback,
@@ -86,8 +82,10 @@ function registerTrigger(
86
82
  triggers.push(trigger);
87
83
  triggers.sort((a, b) => b.priority - a.priority);
88
84
 
85
+ // By identity: an unsubscribe kept across a restart (resetTriggers) must
86
+ // not remove a watcher added since
89
87
  return () => {
90
- triggers = triggers.filter((t) => t.id !== id);
88
+ triggers = triggers.filter((t) => t !== trigger);
91
89
  };
92
90
  }
93
91
 
@@ -177,7 +175,7 @@ function runCheckLoop(): void {
177
175
  anyFired = true;
178
176
 
179
177
  if (trigger.options?.once) {
180
- triggers = triggers.filter((t) => t.id !== trigger.id);
178
+ triggers = triggers.filter((t) => t !== trigger);
181
179
  }
182
180
 
183
181
  fireTrigger(trigger);
@@ -267,7 +265,6 @@ export function reinitTriggerState(): void {
267
265
  export function resetTriggers(): void {
268
266
  triggers = [];
269
267
  dialogQueue = [];
270
- nextId = 0;
271
268
  dialogHostCallbacks?.closeAll();
272
269
  }
273
270
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Passage counters: the visit and render counts of the story state, keyed
3
+ * by passage name.
4
+ *
5
+ * A passage may have any name, including `constructor`, `toString` or
6
+ * `__proto__`, so the counters only ever read their own entries: a passage
7
+ * that was never counted counts 0, never an Object.prototype member. The
8
+ * store keeps them without a prototype, so writing the count of a passage
9
+ * named `__proto__` stores an entry instead of replacing the prototype.
10
+ * Counters from outside the store (a loaded save) go through
11
+ * createCounts(), which keeps only their own entries that are counts.
12
+ */
13
+
14
+ export type Counts = Record<string, number>;
15
+
16
+ const isCount = (value: unknown): value is number =>
17
+ typeof value === 'number' && Number.isInteger(value) && value >= 0;
18
+
19
+ /** The count of `name` in `counts`: its own entry, or 0. */
20
+ export function countOf(
21
+ counts: object | null | undefined,
22
+ name: string,
23
+ ): number {
24
+ if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
25
+ const value = (counts as Record<string, unknown>)[name];
26
+ return isCount(value) ? value : 0;
27
+ }
28
+
29
+ /**
30
+ * Counters with no prototype holding the own entries of `source` that are
31
+ * counts, with `name` (if given) counted once more. Immer copies the
32
+ * counters on every write anyway, so counting into a fresh copy costs no
33
+ * more than counting in place.
34
+ */
35
+ export function createCounts(source?: object | null, name?: string): Counts {
36
+ const counts = Object.create(null) as Counts;
37
+ if (source) {
38
+ for (const key of Object.keys(source)) {
39
+ const value = (source as Record<string, unknown>)[key];
40
+ if (isCount(value)) counts[key] = value;
41
+ }
42
+ }
43
+ if (name !== undefined) counts[name] = countOf(source, name) + 1;
44
+ return counts;
45
+ }