@rohal12/spindle 0.59.14 → 0.59.16

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.
@@ -608,6 +608,7 @@ async function fillSlot(
608
608
  slots.includes(slot) ? slots : [...slots, slot],
609
609
  );
610
610
  }
611
+ announceSlots(ifid);
611
612
  }
612
613
 
613
614
  /** Clear a slot: its pointer, and its entry in the slot index. */
@@ -616,6 +617,7 @@ async function emptySlot(ifid: string, slot?: string): Promise<void> {
616
617
  if (isNamedSlot(slot)) {
617
618
  await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
618
619
  }
620
+ announceSlots(ifid);
619
621
  }
620
622
 
621
623
  /** Clear every slot holding a save whose ID passes `test`. */
@@ -629,6 +631,65 @@ async function emptySlotsHolding(
629
631
  }
630
632
  }
631
633
 
634
+ // --- Other tabs ---
635
+
636
+ // The tabs of a story share its storage, but each keeps which slots hold a
637
+ // save (the store's knownSaves, behind hasSave() and QuickLoad). A tab that
638
+ // fills or empties slots tells the others, which look them up again (#404).
639
+
640
+ const SLOTS_CHANNEL = 'spindle.slots';
641
+ /** Every story's slots: what clearing all data announces. */
642
+ const ALL_STORIES = '*';
643
+ let slotsChannel: BroadcastChannel | null | undefined;
644
+
645
+ function getSlotsChannel(): BroadcastChannel | null {
646
+ if (slotsChannel === undefined) {
647
+ try {
648
+ slotsChannel = new BroadcastChannel(SLOTS_CHANNEL);
649
+ // Node (headless runs) would not exit while it is open
650
+ (slotsChannel as { unref?: () => void }).unref?.();
651
+ } catch {
652
+ // No BroadcastChannel: tabs catch up when shown (see watchSlotChanges)
653
+ slotsChannel = null;
654
+ }
655
+ }
656
+ return slotsChannel;
657
+ }
658
+
659
+ /** Tell the story's other tabs that its slots changed. */
660
+ function announceSlots(ifid: string): void {
661
+ try {
662
+ getSlotsChannel()?.postMessage(ifid);
663
+ } catch {
664
+ // A tab that cannot tell the others leaves them to catch up when shown
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Call `onChange` when another tab may have filled or emptied the story's
670
+ * slots: when it says so, and when this tab is shown again. Returns the
671
+ * function that stops it.
672
+ */
673
+ export function watchSlotChanges(
674
+ ifid: string,
675
+ onChange: () => void,
676
+ ): () => void {
677
+ const channel = getSlotsChannel();
678
+ const onMessage = (event: MessageEvent) => {
679
+ if (event.data === ifid || event.data === ALL_STORIES) onChange();
680
+ };
681
+ const onShown = () => {
682
+ if (document.visibilityState === 'visible') onChange();
683
+ };
684
+ const doc = typeof document === 'undefined' ? undefined : document;
685
+ channel?.addEventListener('message', onMessage);
686
+ doc?.addEventListener('visibilitychange', onShown);
687
+ return () => {
688
+ channel?.removeEventListener('message', onMessage);
689
+ doc?.removeEventListener('visibilitychange', onShown);
690
+ };
691
+ }
692
+
632
693
  /** The `custom` keys that say which slot a save lives in. */
633
694
  const slotCustom = (slot: string | undefined) => ({
634
695
  isAutosave: !isNamedSlot(slot),
@@ -976,6 +1037,7 @@ export function clearGameData(ifid: string): Promise<void> {
976
1037
  await backend.deletePlaythroughsByIfid(ifid);
977
1038
  await backend.deleteMetaByIfid(ifid);
978
1039
  clearSession(ifid);
1040
+ announceSlots(ifid);
979
1041
  });
980
1042
  }
981
1043
 
@@ -997,6 +1059,7 @@ async function clearAllDataNow(): Promise<void> {
997
1059
  } catch {
998
1060
  // no sessionStorage (or access denied): nothing to clear
999
1061
  }
1062
+ announceSlots(ALL_STORIES);
1000
1063
  }
1001
1064
 
1002
1065
  /**
package/src/store.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  loadSlotSave,
35
35
  adoptPlaythrough,
36
36
  populateKnownSaves,
37
+ watchSlotChanges,
37
38
  getSlotSaveInfo,
38
39
  listSlotSaves,
39
40
  deleteSlotSave,
@@ -65,6 +66,7 @@ import {
65
66
  type PRNGSnapshot,
66
67
  } from './prng';
67
68
  import { errorMessage } from './utils/error-message';
69
+ import { createListeners } from './utils/listeners';
68
70
  import {
69
71
  routeStoreUpdate,
70
72
  runWithCommittedMutations,
@@ -444,6 +446,16 @@ function startVariables(defaults: Record<string, unknown>): Namespace {
444
446
  return initialVars;
445
447
  }
446
448
 
449
+ /**
450
+ * Fresh transient variables from their declared defaults (init, restart,
451
+ * load). Transients are never saved, so an instance of an unregistered
452
+ * class (an engine, a library object) is kept as it is, as mutation code
453
+ * keeps it (#321): a copy would lose its class and methods (#398).
454
+ */
455
+ function freshTransients(defaults: Record<string, unknown>): Namespace {
456
+ return createNamespace(deepClone(defaults, { keepUnregistered: true }));
457
+ }
458
+
447
459
  /**
448
460
  * Enter the start passage with `variables`, as the only history moment
449
461
  * (init, restart). Call it inside a store update.
@@ -457,7 +469,7 @@ function enterStart(
457
469
  state.currentPassage = passage;
458
470
  state.navigationId++;
459
471
  state.variables = variables;
460
- state.transient = createNamespace(deepClone(transientDefaults));
472
+ state.transient = freshTransients(transientDefaults);
461
473
  state.temporary = createNamespace();
462
474
  state.history = [{ passage, timestamp: Date.now() }];
463
475
  state.historyIndex = 0;
@@ -652,9 +664,25 @@ let slotLoadApplying: number | null = null;
652
664
 
653
665
  /** A replacement of the game state applied at its call. */
654
666
  function replaceStateNow(): void {
655
- latestStateApplied = ++stateReplacementsIssued;
667
+ applyReplacement(++stateReplacementsIssued);
656
668
  }
657
669
 
670
+ const replacementListeners = createListeners();
671
+
672
+ /** Record that replacement `n` is applied, and tell the listeners. */
673
+ function applyReplacement(n: number): void {
674
+ latestStateApplied = n;
675
+ replacementListeners.notify();
676
+ }
677
+
678
+ /**
679
+ * Call `listener` whenever a boot, restart or load replaces the game state:
680
+ * work the replaced game started that would write into the new one later (a
681
+ * click body's timer in the story interface, which stays mounted) is to
682
+ * stop (#401).
683
+ */
684
+ export const subscribeStateReplacement = replacementListeners.subscribe;
685
+
658
686
  // ---------------------------------------------------------------------------
659
687
  // Runtime handler cleanup (auto-unsub on restart)
660
688
  // ---------------------------------------------------------------------------
@@ -1112,6 +1140,18 @@ function recordKnownSave(
1112
1140
  });
1113
1141
  }
1114
1142
 
1143
+ /** Look up which slots hold a save again, as an operation queued now. */
1144
+ function refreshKnownSaves(set: StoreSet, ifid: string): Promise<void> {
1145
+ return populateKnownSaves(ifid).then((known) => {
1146
+ set((state) => {
1147
+ state.knownSaves = known;
1148
+ });
1149
+ });
1150
+ }
1151
+
1152
+ /** Stops following the slot changes of other tabs (see init). */
1153
+ let stopWatchingSlots: (() => void) | undefined;
1154
+
1115
1155
  /**
1116
1156
  * Queue the clearing of saved data, then restart now: the new playthrough
1117
1157
  * is stored after it, and operations issued from here on belong to the new
@@ -1232,6 +1272,15 @@ export const useStoryStore = create<StoryState>()(
1232
1272
  return '';
1233
1273
  }),
1234
1274
  );
1275
+ // Another tab of the story saving or deleting changes the slots too
1276
+ // (#404). Looked up in an operation queued then, so after those issued
1277
+ // before; operations issued later update the cache after it.
1278
+ stopWatchingSlots?.();
1279
+ stopWatchingSlots = watchSlotChanges(storyData.ifid, () => {
1280
+ refreshKnownSaves(set, storyData.ifid).catch(() => {
1281
+ // The cache keeps what it had; the next change tries again
1282
+ });
1283
+ });
1235
1284
  },
1236
1285
 
1237
1286
  navigate: (passageName: string) => {
@@ -1586,12 +1635,7 @@ export const useStoryStore = create<StoryState>()(
1586
1635
  }
1587
1636
 
1588
1637
  return reported(
1589
- deletion.then(async () => {
1590
- const known = await populateKnownSaves(storyData.ifid);
1591
- set((state) => {
1592
- state.knownSaves = known;
1593
- });
1594
- }),
1638
+ deletion.then(() => refreshKnownSaves(set, storyData.ifid)),
1595
1639
  'spindle: failed to delete playthrough',
1596
1640
  );
1597
1641
  },
@@ -1662,7 +1706,7 @@ export const useStoryStore = create<StoryState>()(
1662
1706
  // Before anything is replaced: an incompatible payload leaves the
1663
1707
  // running game as it is
1664
1708
  assertPassagesExist(get().storyData, payload);
1665
- latestStateApplied = replacement;
1709
+ applyReplacement(replacement);
1666
1710
 
1667
1711
  emit('beforeload', slot);
1668
1712
 
@@ -1719,7 +1763,7 @@ export const useStoryStore = create<StoryState>()(
1719
1763
  state.visitCounts = createCounts(payload.visitCounts);
1720
1764
  state.renderCounts = createCounts(payload.renderCounts);
1721
1765
  state.temporary = createNamespace();
1722
- state.transient = createNamespace(deepClone(get().transientDefaults));
1766
+ state.transient = freshTransients(get().transientDefaults);
1723
1767
  });
1724
1768
 
1725
1769
  // The watchers of the loaded game (a payload without any, from an
package/src/story-api.ts CHANGED
@@ -60,9 +60,10 @@ export type { StoryAction };
60
60
  export type { MacroMetadata };
61
61
 
62
62
  // Deferred-render promise lifecycle.
63
- // deferRender() creates a promise; ready() resolves it.
63
+ // deferRender() creates a promise; ready() resolves it. A deferRender() while
64
+ // one is pending keeps that promise: boot may already wait on it (#403).
64
65
  // index.tsx reads getReadyPromise() AFTER render(), which is after both
65
- // author JS and storyinit have run, so it always gets the final promise.
66
+ // author JS and storyinit have run.
66
67
  let readyResolve: (() => void) | null = null;
67
68
  let readyPromise: Promise<void> | null = null;
68
69
 
@@ -518,7 +519,7 @@ function createStoryAPI(): StoryAPI {
518
519
 
519
520
  deferRender(): void {
520
521
  useStoryStore.getState().deferRender();
521
- readyPromise = new Promise<void>((resolve) => {
522
+ readyPromise ??= new Promise<void>((resolve) => {
522
523
  readyResolve = resolve;
523
524
  });
524
525
  },
@@ -68,18 +68,30 @@ const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
68
68
  boolean: Object(false),
69
69
  };
70
70
 
71
- function inferSchema(value: unknown): FieldSchema {
71
+ /**
72
+ * The schema of a default value. An object met again (a backreference, a
73
+ * shared subtree) has the schema made for it the first time, so a cyclic
74
+ * default gives a schema that refers to itself instead of recursing forever.
75
+ */
76
+ function inferSchema(
77
+ value: unknown,
78
+ seen = new Map<object, FieldSchema>(),
79
+ ): FieldSchema {
72
80
  if (Array.isArray(value)) {
73
81
  return { type: 'array' };
74
82
  }
75
83
  // A default of null means "nothing yet"; the value may later be anything
76
84
  if (value === null) return { type: 'null' };
77
85
  if (typeof value === 'object') {
86
+ const known = seen.get(value);
87
+ if (known) return known;
78
88
  const fields = new Map<string, FieldSchema>();
89
+ const schema: FieldSchema = { type: 'object', fields };
90
+ seen.set(value, schema);
79
91
  for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
80
- fields.set(key, inferSchema(val));
92
+ fields.set(key, inferSchema(val, seen));
81
93
  }
82
- return { type: 'object', fields };
94
+ return schema;
83
95
  }
84
96
  const jsType = typeof value;
85
97
  if (!VALID_VAR_TYPES.has(jsType)) {
package/src/structural.ts CHANGED
@@ -12,7 +12,17 @@
12
12
  import { registeredClassName } from './class-registry';
13
13
  import { hasOwn, setOwn } from './utils/namespace';
14
14
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
15
- import { extraKeys, isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
15
+ import {
16
+ collectionSize,
17
+ extraKeys,
18
+ isAtomic,
19
+ isBoxed,
20
+ isTemporal,
21
+ mapEntries,
22
+ mapGet,
23
+ mapSet,
24
+ setMembers,
25
+ } from './utils/value-kinds';
16
26
 
17
27
  export { isAtomic };
18
28
 
@@ -25,13 +35,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
35
  }
26
36
 
27
37
  /**
28
- * `empty`, a new collection (Array, Map or Set) standing for `obj`, made
29
- * like it: an instance of a registered subclass keeps its class and its
30
- * extra keys (#391), with the values `value` gives for them; other
31
- * collections are plain. `made` gets it before its keys are filled, so
38
+ * `empty`, a new built-in (an Array, Map, Set, Date or RegExp) standing for
39
+ * `obj`, made like it: an instance of a registered subclass keeps its class
40
+ * and its extra keys (#391, #393), with the values `value` gives for them;
41
+ * other built-ins are plain. `made` gets it before its keys are filled, so
32
42
  * that cycles through it find it.
33
43
  */
34
- function collectionLike<C extends object>(
44
+ function builtinLike<C extends object>(
35
45
  obj: object,
36
46
  empty: C,
37
47
  made: (copy: C) => void,
@@ -103,11 +113,17 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
103
113
  }
104
114
 
105
115
  function cloneBuiltin(val: object): object | undefined {
106
- if (val instanceof Date) return keep(val, new Date(val.getTime()));
116
+ // Read with the built-in getters: a subclass may override them
117
+ if (val instanceof Date) {
118
+ return builtin(val, new Date(Date.prototype.getTime.call(val)));
119
+ }
107
120
  if (val instanceof RegExp) {
108
- const copy = new RegExp(val.source, val.flags);
121
+ const copy = new RegExp(
122
+ Reflect.get(RegExp.prototype, 'source', val) as string,
123
+ Reflect.get(RegExp.prototype, 'flags', val) as string,
124
+ );
109
125
  copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
110
- return keep(val, copy);
126
+ return builtin(val, copy);
111
127
  }
112
128
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
113
129
  if (ArrayBuffer.isView(val)) {
@@ -151,9 +167,9 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
151
167
  : copy;
152
168
  }
153
169
 
154
- /** `empty`, made the copy of the collection `obj` (see collectionLike). */
155
- const collection = <C extends object>(obj: object, empty: C): C =>
156
- collectionLike(
170
+ /** `empty`, made the copy of the built-in `obj` (see builtinLike). */
171
+ const builtin = <C extends object>(obj: object, empty: C): C =>
172
+ builtinLike(
157
173
  obj,
158
174
  empty,
159
175
  (copy) => keep(obj, copy),
@@ -168,30 +184,33 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
168
184
 
169
185
  if (Array.isArray(val)) {
170
186
  // Holes stay holes
171
- const arr = collection(obj, new Array(val.length) as unknown[]);
187
+ const arr = builtin(obj, new Array(val.length) as unknown[]);
172
188
  for (let i = 0; i < val.length; i++) {
173
189
  if (i in val) arr[i] = clone(val[i]);
174
190
  }
175
191
  return arr;
176
192
  }
177
193
 
178
- // Filled with the built-in methods: a subclass may override them
194
+ // Read and filled with the built-in methods: a subclass may override
195
+ // them (see mapEntries)
179
196
  if (val instanceof Map) {
180
- const copy = collection(obj, new Map());
181
- for (const [k, v] of val) {
197
+ const copy = builtin(obj, new Map());
198
+ for (const [k, v] of mapEntries(val)) {
182
199
  Map.prototype.set.call(copy, settled(clone(k), k), clone(v));
183
200
  }
184
201
  return copy;
185
202
  }
186
203
 
187
204
  if (val instanceof Set) {
188
- const copy = collection(obj, new Set());
189
- for (const v of val) Set.prototype.add.call(copy, settled(clone(v), v));
205
+ const copy = builtin(obj, new Set());
206
+ for (const v of setMembers(val)) {
207
+ Set.prototype.add.call(copy, settled(clone(v), v));
208
+ }
190
209
  return copy;
191
210
  }
192
211
 
193
- const builtin = cloneBuiltin(obj);
194
- if (builtin !== undefined) return builtin;
212
+ const copied = cloneBuiltin(obj);
213
+ if (copied !== undefined) return copied;
195
214
 
196
215
  // A registered class instance keeps its class, and a plain object its
197
216
  // prototype (which may be null). An instance of an unregistered class
@@ -332,13 +351,13 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
332
351
  /** What the result holds at `key` of `c`. */
333
352
  const shareKey = (key: string) =>
334
353
  share(hasOwn(pr, key) ? pr[key] : undefined, cr[key]);
335
- /** `empty`, made the result for the collection `c` (see collectionLike). */
354
+ /** `empty`, made the result for the collection `c` (see builtinLike). */
336
355
  const collection = <C extends object>(empty: C): C =>
337
- collectionLike(c, empty, (out) => done.set(c, out), shareKey);
356
+ builtinLike(c, empty, (out) => done.set(c, out), shareKey);
338
357
  if (c instanceof Map && p instanceof Map) {
339
358
  const out = collection(new Map());
340
- for (const [k, v] of c) {
341
- Map.prototype.set.call(out, fresh(k), share(p.get(k), v));
359
+ for (const [k, v] of mapEntries(c)) {
360
+ Map.prototype.set.call(out, fresh(k), share(mapGet(p, k), v));
342
361
  }
343
362
  return out;
344
363
  }
@@ -381,9 +400,20 @@ function equalBuiltin(
381
400
  b: object,
382
401
  assumed: Pairs,
383
402
  ): boolean | undefined {
384
- if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
403
+ if (a instanceof Date) {
404
+ const time = Date.prototype.getTime;
405
+ return (
406
+ Object.is(time.call(a), time.call(b as Date)) &&
407
+ equalExtraKeys(a, b, assumed)
408
+ );
409
+ }
385
410
  if (a instanceof RegExp) {
386
- return String(a) === String(b) && a.lastIndex === (b as RegExp).lastIndex;
411
+ const text = RegExp.prototype.toString;
412
+ return (
413
+ text.call(a) === text.call(b as RegExp) &&
414
+ a.lastIndex === (b as RegExp).lastIndex &&
415
+ equalExtraKeys(a, b, assumed)
416
+ );
387
417
  }
388
418
  if (ArrayBuffer.isView(a)) {
389
419
  // A view is its place in its backing buffer, and the whole buffer (which
@@ -435,20 +465,22 @@ function equalKeys(
435
465
  return true;
436
466
  }
437
467
 
438
- /** Built-in collection prototypes, whose instances hold no extra keys. */
439
- const COLLECTION_PROTOS: ReadonlySet<object | null> = new Set([
468
+ /** Built-in prototypes (see builtinLike), whose instances hold no extra keys. */
469
+ const BUILTIN_PROTOS: ReadonlySet<object | null> = new Set([
440
470
  Array.prototype,
441
471
  Map.prototype,
442
472
  Set.prototype,
473
+ Date.prototype,
474
+ RegExp.prototype,
443
475
  ]);
444
476
 
445
477
  /**
446
- * Equality of the extra keys (see extraKeys) of two collections of one
478
+ * Equality of the extra keys (see extraKeys) of two built-ins of one
447
479
  * class: those of a subclass instance are part of its value.
448
480
  */
449
481
  function equalExtraKeys(a: object, b: object, assumed: Pairs): boolean {
450
482
  return (
451
- COLLECTION_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
483
+ BUILTIN_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
452
484
  equalKeys(a, b, assumed, extraKeys)
453
485
  );
454
486
  }
@@ -470,9 +502,9 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
470
502
 
471
503
  if (a instanceof Map || a instanceof Set) {
472
504
  const bc = b as Map<unknown, unknown> | Set<unknown>;
473
- if (a.size !== bc.size) return false;
474
- const ai = a.entries();
475
- const bi = bc.entries();
505
+ if (collectionSize(a) !== collectionSize(bc)) return false;
506
+ const ai = a instanceof Map ? mapEntries(a) : setMembers(a);
507
+ const bi = bc instanceof Map ? mapEntries(bc) : setMembers(bc);
476
508
  for (
477
509
  let x = ai.next(), y = bi.next();
478
510
  !x.done;
@@ -762,13 +794,15 @@ function entryChildren(value: object): [string, unknown][] | undefined {
762
794
  const children: [string, unknown][] = [];
763
795
  if (value instanceof Map) {
764
796
  let index = 0;
765
- for (const [k, v] of value) {
797
+ for (const [k, v] of mapEntries(value)) {
766
798
  children.push([entryKey('k', index), k], [entryKey('v', index), v]);
767
799
  index++;
768
800
  }
769
801
  } else if (value instanceof Set) {
770
802
  let index = 0;
771
- for (const member of value) children.push([entryKey('s', index++), member]);
803
+ for (const member of setMembers(value)) {
804
+ children.push([entryKey('s', index++), member]);
805
+ }
772
806
  } else if (value instanceof Error) {
773
807
  for (const key of ['cause', 'errors'] as const) {
774
808
  if (hasOwn(value, key)) {
@@ -819,8 +853,9 @@ function setEntryValue(
819
853
  const holder = getByEntryPath(root, path.slice(0, -1));
820
854
  const match = /^\0v(\d+)$/.exec(path[path.length - 1]!);
821
855
  if (!(holder instanceof Map) || !match) return false;
822
- const key = [...holder.keys()][Number(match[1])];
823
- holder.set(key, value);
856
+ const entry = [...mapEntries(holder)][Number(match[1])];
857
+ if (!entry) return false;
858
+ mapSet(holder, entry[0], value);
824
859
  return true;
825
860
  }
826
861
 
@@ -921,7 +956,8 @@ function aliasMoved(
921
956
  if (xs && ys) {
922
957
  if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
923
958
  if (x instanceof Map || x instanceof Set) {
924
- if (x.size !== (y as Map<unknown, unknown>).size) return false;
959
+ const ySize = collectionSize(y as Map<unknown, unknown>);
960
+ if (collectionSize(x) !== ySize) return false;
925
961
  }
926
962
  const held = new Map(ys);
927
963
  for (const [segment, a] of xs) {
package/src/triggers.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { evaluate } from './expression';
2
2
  import { executeMutation, readState } from './execute-mutation';
3
3
  import { useStoryStore } from './store';
4
+ import { createListeners } from './utils/listeners';
4
5
 
5
6
  export interface WatchOptions {
6
7
  goto?: string;
@@ -294,12 +295,32 @@ export function reinitTriggerState(): void {
294
295
  }
295
296
  }
296
297
 
298
+ /**
299
+ * Forget every watcher and close every dialog on display, whoever opened it
300
+ * (restart): a `{dialog}` or menubar dialog of the story interface, which
301
+ * stays mounted, would otherwise stay open over the new game (#397).
302
+ */
297
303
  export function resetTriggers(): void {
298
304
  triggers = [];
299
- dialogQueue = [];
300
- dialogHostCallbacks?.closeAll();
305
+ resets++;
306
+ closeAllOpenDialogs();
307
+ resetListeners.notify();
308
+ }
309
+
310
+ let resets = 0;
311
+ const resetListeners = createListeners();
312
+
313
+ /** How many times resetTriggers has forgotten every watcher. */
314
+ export function triggerResets(): number {
315
+ return resets;
301
316
  }
302
317
 
318
+ /**
319
+ * Call `listener` after each resetTriggers (a restart): a {watch} that stays
320
+ * mounted, in the story interface, registers its watcher again (#402).
321
+ */
322
+ export const subscribeTriggerResets = resetListeners.subscribe;
323
+
303
324
  export function subscribeTriggerDialogs(cb: () => void): () => void {
304
325
  dialogNotify = cb;
305
326
  // Flush any queued dialogs