@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.
- package/README.md +1 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +814 -321
- package/dist/pkg/story-variables.js +349 -152
- package/package.json +1 -1
- package/src/class-registry.ts +105 -35
- package/src/components/Passage.tsx +21 -13
- package/src/components/PassageDialog.tsx +12 -2
- package/src/components/macros/For.tsx +16 -9
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/components/macros/Watch.tsx +24 -4
- package/src/components/macros/detached-body.tsx +17 -6
- package/src/define-macro.ts +11 -2
- package/src/execute-mutation.ts +3 -2
- package/src/markup/render.tsx +17 -1
- package/src/markup/spindle.peggy +16 -0
- package/src/runtime-errors.ts +4 -8
- package/src/saves/save-manager.ts +63 -0
- package/src/store.ts +54 -10
- package/src/story-api.ts +4 -3
- package/src/story-variables.ts +15 -3
- package/src/structural.ts +74 -38
- package/src/triggers.ts +23 -2
- package/src/utils/control-edits.ts +81 -30
- package/src/utils/listeners.ts +23 -0
- package/src/utils/object-path.ts +18 -6
- package/src/utils/stable-key.ts +73 -40
- package/src/utils/value-kinds.ts +42 -0
- package/types/index.d.ts +7 -2
- package/types/tooling.d.ts +5 -1
|
@@ -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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
522
|
+
readyPromise ??= new Promise<void>((resolve) => {
|
|
522
523
|
readyResolve = resolve;
|
|
523
524
|
});
|
|
524
525
|
},
|
package/src/story-variables.ts
CHANGED
|
@@ -68,18 +68,30 @@ const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
|
|
|
68
68
|
boolean: Object(false),
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
|
|
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
|
|
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 {
|
|
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
|
|
29
|
-
* like it: an instance of a registered subclass keeps its class
|
|
30
|
-
* extra keys (#391), with the values `value` gives for them;
|
|
31
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
155
|
-
const
|
|
156
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
189
|
-
for (const v of val)
|
|
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
|
|
194
|
-
if (
|
|
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
|
|
354
|
+
/** `empty`, made the result for the collection `c` (see builtinLike). */
|
|
336
355
|
const collection = <C extends object>(empty: C): C =>
|
|
337
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
439
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
474
|
-
const ai = a
|
|
475
|
-
const bi = bc
|
|
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)
|
|
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
|
|
823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|