@rohal12/spindle 0.59.15 → 0.59.17
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 +354 -109
- package/dist/pkg/story-variables.js +36 -3
- package/package.json +1 -1
- package/src/components/macros/For.tsx +16 -9
- package/src/components/macros/PassageDisplay.tsx +9 -1
- package/src/components/macros/Repeat.tsx +6 -1
- package/src/components/macros/Timed.tsx +5 -1
- package/src/components/macros/Watch.tsx +24 -4
- package/src/components/macros/detached-body.tsx +31 -9
- package/src/define-macro.ts +21 -4
- package/src/hooks/use-modal-focus.ts +2 -0
- package/src/hooks/use-story-fields.ts +8 -0
- package/src/runtime-errors.ts +4 -8
- package/src/saves/save-manager.ts +63 -0
- package/src/store.ts +42 -8
- package/src/story-api.ts +4 -3
- package/src/story-variables.ts +15 -3
- package/src/structural.ts +46 -13
- package/src/triggers.ts +17 -0
- package/src/utils/control-edits.ts +81 -30
- package/src/utils/listeners.ts +23 -0
- package/src/utils/object-path.ts +31 -8
- package/src/utils/stable-key.ts +73 -44
- package/types/index.d.ts +4 -1
- package/types/tooling.d.ts +5 -1
|
@@ -10125,7 +10125,30 @@ function errorMessage(err, fallback) {
|
|
|
10125
10125
|
return fallback ?? String(err);
|
|
10126
10126
|
}
|
|
10127
10127
|
//#endregion
|
|
10128
|
+
//#region src/utils/listeners.ts
|
|
10129
|
+
/**
|
|
10130
|
+
* A set of listeners to call when something happens: `subscribe` adds one and
|
|
10131
|
+
* returns the function that removes it, `notify` calls each one. A listener
|
|
10132
|
+
* removed or added while they are called is called or not as the set was
|
|
10133
|
+
* when `notify` started.
|
|
10134
|
+
*/
|
|
10135
|
+
function createListeners() {
|
|
10136
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
10137
|
+
return {
|
|
10138
|
+
subscribe(listener) {
|
|
10139
|
+
listeners.add(listener);
|
|
10140
|
+
return () => {
|
|
10141
|
+
listeners.delete(listener);
|
|
10142
|
+
};
|
|
10143
|
+
},
|
|
10144
|
+
notify() {
|
|
10145
|
+
for (const listener of [...listeners]) listener();
|
|
10146
|
+
}
|
|
10147
|
+
};
|
|
10148
|
+
}
|
|
10149
|
+
//#endregion
|
|
10128
10150
|
//#region src/runtime-errors.ts
|
|
10151
|
+
var listeners = createListeners();
|
|
10129
10152
|
/**
|
|
10130
10153
|
* The error for `name`, a passage name that names no passage, used in the
|
|
10131
10154
|
* passage `from`.
|
|
@@ -10133,6 +10156,7 @@ function errorMessage(err, fallback) {
|
|
|
10133
10156
|
function noPassageError(name, from) {
|
|
10134
10157
|
return /* @__PURE__ */ new Error(`No passage named ${JSON.stringify(name)} (in passage ${JSON.stringify(from)})`);
|
|
10135
10158
|
}
|
|
10159
|
+
listeners.subscribe;
|
|
10136
10160
|
//#endregion
|
|
10137
10161
|
//#region src/components/macros/arg-utils.ts
|
|
10138
10162
|
/**
|
|
@@ -11250,16 +11274,25 @@ var VALID_VAR_TYPES = new Set([
|
|
|
11250
11274
|
"string",
|
|
11251
11275
|
"boolean"
|
|
11252
11276
|
]);
|
|
11253
|
-
|
|
11277
|
+
/**
|
|
11278
|
+
* The schema of a default value. An object met again (a backreference, a
|
|
11279
|
+
* shared subtree) has the schema made for it the first time, so a cyclic
|
|
11280
|
+
* default gives a schema that refers to itself instead of recursing forever.
|
|
11281
|
+
*/
|
|
11282
|
+
function inferSchema(value, seen = /* @__PURE__ */ new Map()) {
|
|
11254
11283
|
if (Array.isArray(value)) return { type: "array" };
|
|
11255
11284
|
if (value === null) return { type: "null" };
|
|
11256
11285
|
if (typeof value === "object") {
|
|
11286
|
+
const known = seen.get(value);
|
|
11287
|
+
if (known) return known;
|
|
11257
11288
|
const fields = /* @__PURE__ */ new Map();
|
|
11258
|
-
|
|
11259
|
-
return {
|
|
11289
|
+
const schema = {
|
|
11260
11290
|
type: "object",
|
|
11261
11291
|
fields
|
|
11262
11292
|
};
|
|
11293
|
+
seen.set(value, schema);
|
|
11294
|
+
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val, seen));
|
|
11295
|
+
return schema;
|
|
11263
11296
|
}
|
|
11264
11297
|
const jsType = typeof value;
|
|
11265
11298
|
if (!VALID_VAR_TYPES.has(jsType)) throw new Error(`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`);
|
package/package.json
CHANGED
|
@@ -3,7 +3,11 @@ import { LocalsValuesContext } from '../../markup/render';
|
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
import { MacroError } from './MacroError';
|
|
5
5
|
import { stableKey } from '../../utils/stable-key';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ItemEditContext,
|
|
8
|
+
controlEditVersion,
|
|
9
|
+
editedItems,
|
|
10
|
+
} from '../../utils/control-edits';
|
|
7
11
|
import type { ASTNode } from '../../markup/ast';
|
|
8
12
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
13
|
import { readState } from '../../execute-mutation';
|
|
@@ -129,18 +133,17 @@ defineMacro({
|
|
|
129
133
|
|
|
130
134
|
const { itemVar, indexVar } = loop;
|
|
131
135
|
// An iteration is remounted when its item's contents change, so mount-only
|
|
132
|
-
// macros run again for the new item (#45) -- unless the change is
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
const
|
|
136
|
+
// macros run again for the new item (#45) -- unless the change is an edit
|
|
137
|
+
// of the item (see control-edits): a reader typing into a control, which
|
|
138
|
+
// a remount would take the focus from, or code the iterations ran, which
|
|
139
|
+
// a remount would run again (#400).
|
|
140
|
+
const edited = editedItems(keys.edits, readState());
|
|
137
141
|
keys.edits = controlEditVersion();
|
|
138
142
|
const generations = list.map((item, i) => {
|
|
139
143
|
const contents = stableKey(item);
|
|
140
144
|
if (
|
|
141
145
|
keys.generations[i] === undefined ||
|
|
142
|
-
(contents !== keys.contents[i] &&
|
|
143
|
-
!editedItem(since, variables, list, item, i))
|
|
146
|
+
(contents !== keys.contents[i] && !edited(list, item, i))
|
|
144
147
|
) {
|
|
145
148
|
keys.generations[i] = keys.next++;
|
|
146
149
|
}
|
|
@@ -160,7 +163,11 @@ defineMacro({
|
|
|
160
163
|
/>
|
|
161
164
|
));
|
|
162
165
|
|
|
163
|
-
return ctx.wrap(
|
|
166
|
+
return ctx.wrap(
|
|
167
|
+
<ItemEditContext.Provider value={true}>
|
|
168
|
+
{content}
|
|
169
|
+
</ItemEditContext.Provider>,
|
|
170
|
+
);
|
|
164
171
|
},
|
|
165
172
|
text({ rawArgs, children = [] }, ctx) {
|
|
166
173
|
const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
|
|
@@ -9,6 +9,7 @@ import { useStoryStore, type StoryState } from '../../store';
|
|
|
9
9
|
import {
|
|
10
10
|
useStoryFields,
|
|
11
11
|
FrozenStateContext,
|
|
12
|
+
OutgoingContext,
|
|
12
13
|
} from '../../hooks/use-story-fields';
|
|
13
14
|
import { Passage, renderPassageContent } from '../Passage';
|
|
14
15
|
import { defineMacro } from '../../define-macro';
|
|
@@ -140,6 +141,11 @@ defineMacro({
|
|
|
140
141
|
(state: StoryState) => state.navigationId !== displayedId,
|
|
141
142
|
[displayedId],
|
|
142
143
|
);
|
|
144
|
+
// Read live: a timer asks it before the outgoing tree has unmounted
|
|
145
|
+
const hasLeft = useCallback(
|
|
146
|
+
() => isOutgoing(useStoryStore.getState()),
|
|
147
|
+
[isOutgoing],
|
|
148
|
+
);
|
|
143
149
|
const lastElement = useRef<preact.VNode | null>(null);
|
|
144
150
|
|
|
145
151
|
// Container ref for snapshot insertion
|
|
@@ -350,7 +356,9 @@ defineMacro({
|
|
|
350
356
|
>
|
|
351
357
|
{passageElement && (
|
|
352
358
|
<FrozenStateContext.Provider value={isOutgoing}>
|
|
353
|
-
{
|
|
359
|
+
<OutgoingContext.Provider value={hasLeft}>
|
|
360
|
+
{passageElement}
|
|
361
|
+
</OutgoingContext.Provider>
|
|
354
362
|
</FrozenStateContext.Provider>
|
|
355
363
|
)}
|
|
356
364
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
3
|
import { DELAY_PARAMETER } from './macro-args';
|
|
4
|
+
import { OutgoingContext } from '../../hooks/use-story-fields';
|
|
4
5
|
|
|
5
6
|
export const RepeatContext = createContext<{ stop: () => void }>({
|
|
6
7
|
stop: () => {},
|
|
@@ -12,17 +13,21 @@ defineMacro({
|
|
|
12
13
|
interpolate: true,
|
|
13
14
|
parameters: [DELAY_PARAMETER],
|
|
14
15
|
render({ children = [] }, ctx) {
|
|
15
|
-
const { useState, useEffect, useCallback } = ctx.hooks;
|
|
16
|
+
const { useState, useEffect, useCallback, useContext } = ctx.hooks;
|
|
16
17
|
|
|
17
18
|
const delay = ctx.args.delay ?? 0;
|
|
18
19
|
const [count, setCount] = useState(0);
|
|
19
20
|
const [stopped, setStopped] = useState(false);
|
|
20
21
|
|
|
22
|
+
const hasLeft = useContext(OutgoingContext);
|
|
23
|
+
|
|
21
24
|
const stop = useCallback(() => setStopped(true), []);
|
|
22
25
|
|
|
23
26
|
useEffect(() => {
|
|
24
27
|
if (stopped) return;
|
|
25
28
|
const interval = setInterval(() => {
|
|
29
|
+
// The passage was left: its body must not run again (#410)
|
|
30
|
+
if (hasLeft?.()) return;
|
|
26
31
|
setCount((c) => c + 1);
|
|
27
32
|
}, delay);
|
|
28
33
|
return () => clearInterval(interval);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { DELAY_PARAMETER, parseMacroArgs } from './macro-args';
|
|
3
3
|
import { wrapContent } from './display';
|
|
4
|
+
import { OutgoingContext } from '../../hooks/use-story-fields';
|
|
4
5
|
|
|
5
6
|
/** The parameters of {timed} and of each {next}: the delay before it shows. */
|
|
6
7
|
const TIMED_PARAMETERS = [DELAY_PARAMETER] as const;
|
|
@@ -11,7 +12,8 @@ defineMacro({
|
|
|
11
12
|
interpolate: true,
|
|
12
13
|
parameters: TIMED_PARAMETERS,
|
|
13
14
|
render({ branches = [] }, ctx) {
|
|
14
|
-
const { useState, useEffect, useMemo } = ctx.hooks;
|
|
15
|
+
const { useState, useEffect, useMemo, useContext } = ctx.hooks;
|
|
16
|
+
const hasLeft = useContext(OutgoingContext);
|
|
15
17
|
|
|
16
18
|
const sections = useMemo(() => {
|
|
17
19
|
return branches.map((branch) => ({
|
|
@@ -31,6 +33,8 @@ defineMacro({
|
|
|
31
33
|
const delay = sections[nextIndex]!.delay;
|
|
32
34
|
|
|
33
35
|
const timer = setTimeout(() => {
|
|
36
|
+
// The passage was left: its body must not run (#410)
|
|
37
|
+
if (hasLeft?.()) return;
|
|
34
38
|
setVisibleIndex(nextIndex);
|
|
35
39
|
}, delay);
|
|
36
40
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
3
|
+
import { useStoryStore } from '../../store';
|
|
4
|
+
import {
|
|
5
|
+
addMacroTrigger,
|
|
6
|
+
removeTrigger,
|
|
7
|
+
subscribeTriggerResets,
|
|
8
|
+
triggerResets,
|
|
9
|
+
} from '../../triggers';
|
|
3
10
|
|
|
4
11
|
defineMacro({
|
|
5
12
|
name: 'watch',
|
|
@@ -34,9 +41,22 @@ defineMacro({
|
|
|
34
41
|
// Register during render (like {set}) — triggers survive navigation
|
|
35
42
|
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
36
43
|
// Remounts (revisits, re-rendered branches) keep the registered watcher.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
// A {watch} still mounted when a restart forgets the watchers (in the
|
|
45
|
+
// story interface) registers it again for the new game (#402); one in a
|
|
46
|
+
// passage on its way out or a finished click body does not.
|
|
47
|
+
const frozen = hooks.useContext(FrozenStateContext);
|
|
48
|
+
const [, rerender] = hooks.useState(0);
|
|
49
|
+
hooks.useEffect(
|
|
50
|
+
() => subscribeTriggerResets(() => rerender((n) => n + 1)),
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
const registered = hooks.useRef<number | null>(null);
|
|
54
|
+
if (
|
|
55
|
+
registered.current === null ||
|
|
56
|
+
(registered.current !== triggerResets() &&
|
|
57
|
+
!frozen?.(useStoryStore.getState()))
|
|
58
|
+
) {
|
|
59
|
+
registered.current = triggerResets();
|
|
40
60
|
addMacroTrigger(condition, options);
|
|
41
61
|
}
|
|
42
62
|
|
|
@@ -10,11 +10,16 @@ import {
|
|
|
10
10
|
} from '../../markup/render';
|
|
11
11
|
import type { ASTNode } from '../../markup/ast';
|
|
12
12
|
import { useRenderOptions } from '../../hooks/use-render-options';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
FrozenStateContext,
|
|
15
|
+
OutgoingContext,
|
|
16
|
+
} from '../../hooks/use-story-fields';
|
|
14
17
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
15
18
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
19
|
+
import { subscribeStateReplacement } from '../../store';
|
|
16
20
|
import { RepeatContext } from './Repeat';
|
|
17
21
|
import { DialogCloseContext } from '../PassageDialog';
|
|
22
|
+
import { ItemEditContext } from '../../utils/control-edits';
|
|
18
23
|
|
|
19
24
|
const frozenAlways = () => true;
|
|
20
25
|
|
|
@@ -26,13 +31,16 @@ const frozenAlways = () => true;
|
|
|
26
31
|
* once it has rendered, so it runs once per click. It stays mounted
|
|
27
32
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
28
33
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
29
|
-
* finish; the passage leaving cancels it
|
|
34
|
+
* finish; the passage leaving cancels it, and so does a restart or a load
|
|
35
|
+
* replacing the game, which a control of the story interface outlives
|
|
36
|
+
* (#401).
|
|
30
37
|
*
|
|
31
38
|
* A detached render starts with no contexts, so the hook captures the ones
|
|
32
39
|
* the owning macro sees and provides them again: the locals scope (read
|
|
33
40
|
* through a live view, so a local assigned earlier in the body is visible),
|
|
34
41
|
* nobr/inline rendering, the enclosing block widget's {@children}, the
|
|
35
|
-
* enclosing {repeat}'s {stop} and the enclosing dialog's close callback
|
|
42
|
+
* enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
|
|
43
|
+
* (#411) and the owning passage's departure (#410).
|
|
36
44
|
* The body then behaves as if it rendered in place.
|
|
37
45
|
*
|
|
38
46
|
* The body's macros read story state from the store. Run from mutation code
|
|
@@ -47,16 +55,26 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
47
55
|
const widgetChildren = useContext(WidgetChildrenContext);
|
|
48
56
|
const repeat = useContext(RepeatContext);
|
|
49
57
|
const closeDialog = useContext(DialogCloseContext);
|
|
58
|
+
const inIteration = useContext(ItemEditContext);
|
|
59
|
+
const hasLeft = useContext(OutgoingContext);
|
|
50
60
|
|
|
51
61
|
const mounted = useRef<HTMLElement[]>([]);
|
|
52
|
-
useEffect(
|
|
53
|
-
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const unsubscribe = subscribeStateReplacement(() => {
|
|
64
|
+
// After the code that replaced the game, which may be running in one
|
|
65
|
+
// of the bodies (`{do}Story.restart(){/do}`), has finished rendering
|
|
66
|
+
const replaced = mounted.current.splice(0);
|
|
67
|
+
queueMicrotask(() => {
|
|
68
|
+
for (const container of replaced) render(null, container);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return () => {
|
|
72
|
+
unsubscribe();
|
|
54
73
|
for (const container of mounted.current.splice(0)) {
|
|
55
74
|
render(null, container);
|
|
56
75
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
);
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
60
78
|
|
|
61
79
|
const run = (children: ASTNode[]) => {
|
|
62
80
|
const locals = liveLocalsView(updater.getValues);
|
|
@@ -70,7 +88,11 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
70
88
|
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
71
89
|
<RepeatContext.Provider value={repeat}>
|
|
72
90
|
<DialogCloseContext.Provider value={closeDialog}>
|
|
73
|
-
|
|
91
|
+
<ItemEditContext.Provider value={inIteration}>
|
|
92
|
+
<OutgoingContext.Provider value={hasLeft}>
|
|
93
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
94
|
+
</OutgoingContext.Provider>
|
|
95
|
+
</ItemEditContext.Provider>
|
|
74
96
|
</DialogCloseContext.Provider>
|
|
75
97
|
</RepeatContext.Provider>
|
|
76
98
|
</WidgetChildrenContext.Provider>
|
package/src/define-macro.ts
CHANGED
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
useMemo,
|
|
11
11
|
} from 'preact/hooks';
|
|
12
12
|
import { useInterpolate } from './hooks/use-interpolate';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ItemEditContext,
|
|
15
|
+
noteControlEdit,
|
|
16
|
+
runAsItemEdit,
|
|
17
|
+
} from './utils/control-edits';
|
|
14
18
|
import { useMergedLocals } from './hooks/use-merged-locals';
|
|
15
19
|
import { useRenderOptions } from './hooks/use-render-options';
|
|
16
20
|
import {
|
|
@@ -155,6 +159,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
155
159
|
|
|
156
160
|
// Always-on: cssClass + mutation
|
|
157
161
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
162
|
+
const inIteration = useContext(ItemEditContext);
|
|
158
163
|
const renderOptions = useRenderOptions();
|
|
159
164
|
const renderNodes = (
|
|
160
165
|
nodes: ASTNode[],
|
|
@@ -190,7 +195,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
190
195
|
id,
|
|
191
196
|
resolve,
|
|
192
197
|
cls: macroClass(config.name, className),
|
|
193
|
-
|
|
198
|
+
// Code run inside a {for} iteration edits the loop's items (#400)
|
|
199
|
+
mutate: inIteration
|
|
200
|
+
? (code: string) =>
|
|
201
|
+
runAsItemEdit(() => executeMutation(code, getValues(), update))
|
|
202
|
+
: (code: string) => executeMutation(code, getValues(), update),
|
|
194
203
|
update,
|
|
195
204
|
getValues,
|
|
196
205
|
wrap: (content: ComponentChildren): VNode<any> =>
|
|
@@ -207,11 +216,19 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
207
216
|
if (config.storeVar) {
|
|
208
217
|
const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
|
|
209
218
|
|
|
210
|
-
|
|
219
|
+
// Only story variables ($name) can be bound: another sigil would be
|
|
220
|
+
// read as a story key spelled with it (#414)
|
|
221
|
+
const unbindable: Record<string, string> = {
|
|
222
|
+
'%': 'transient variables',
|
|
223
|
+
_: 'temporary variables',
|
|
224
|
+
'@': 'locals',
|
|
225
|
+
};
|
|
226
|
+
const kind = unbindable[firstToken.charAt(0)];
|
|
227
|
+
if (kind) {
|
|
211
228
|
return h(
|
|
212
229
|
'span',
|
|
213
230
|
{ class: 'error' },
|
|
214
|
-
`{${config.name}}:
|
|
231
|
+
`{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`,
|
|
215
232
|
);
|
|
216
233
|
}
|
|
217
234
|
|
|
@@ -15,6 +15,14 @@ export const FrozenStateContext = createContext<
|
|
|
15
15
|
((state: StoryState) => boolean) | null
|
|
16
16
|
>(null);
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
|
|
20
|
+
* been left: it must not run a new body then, for that would write into the
|
|
21
|
+
* destination's state until the outgoing tree unmounts (#410). Null outside
|
|
22
|
+
* a passage.
|
|
23
|
+
*/
|
|
24
|
+
export const OutgoingContext = createContext<(() => boolean) | null>(null);
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
20
28
|
* useStoryStore() selector per field would). The result keeps its identity
|
package/src/runtime-errors.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// errors that would otherwise only reach the browser console.
|
|
3
3
|
|
|
4
4
|
import { errorMessage } from './utils/error-message';
|
|
5
|
+
import { createListeners } from './utils/listeners';
|
|
5
6
|
|
|
6
7
|
/** A runtime error shown on the page until the player dismisses it. */
|
|
7
8
|
export interface RuntimeError {
|
|
@@ -17,11 +18,11 @@ export interface RuntimeError {
|
|
|
17
18
|
|
|
18
19
|
let errors: readonly RuntimeError[] = [];
|
|
19
20
|
let nextId = 1;
|
|
20
|
-
const listeners =
|
|
21
|
+
const listeners = createListeners();
|
|
21
22
|
|
|
22
23
|
function update(next: readonly RuntimeError[]): void {
|
|
23
24
|
errors = next;
|
|
24
|
-
|
|
25
|
+
listeners.notify();
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -67,9 +68,4 @@ export function getRuntimeErrors(): readonly RuntimeError[] {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** Call `listener` whenever the shown errors change; returns unsubscribe. */
|
|
70
|
-
export
|
|
71
|
-
listeners.add(listener);
|
|
72
|
-
return () => {
|
|
73
|
-
listeners.delete(listener);
|
|
74
|
-
};
|
|
75
|
-
}
|
|
71
|
+
export const subscribeRuntimeErrors = listeners.subscribe;
|
|
@@ -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,
|
|
@@ -662,9 +664,25 @@ let slotLoadApplying: number | null = null;
|
|
|
662
664
|
|
|
663
665
|
/** A replacement of the game state applied at its call. */
|
|
664
666
|
function replaceStateNow(): void {
|
|
665
|
-
|
|
667
|
+
applyReplacement(++stateReplacementsIssued);
|
|
666
668
|
}
|
|
667
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
|
+
|
|
668
686
|
// ---------------------------------------------------------------------------
|
|
669
687
|
// Runtime handler cleanup (auto-unsub on restart)
|
|
670
688
|
// ---------------------------------------------------------------------------
|
|
@@ -1122,6 +1140,18 @@ function recordKnownSave(
|
|
|
1122
1140
|
});
|
|
1123
1141
|
}
|
|
1124
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
|
+
|
|
1125
1155
|
/**
|
|
1126
1156
|
* Queue the clearing of saved data, then restart now: the new playthrough
|
|
1127
1157
|
* is stored after it, and operations issued from here on belong to the new
|
|
@@ -1242,6 +1272,15 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1242
1272
|
return '';
|
|
1243
1273
|
}),
|
|
1244
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
|
+
});
|
|
1245
1284
|
},
|
|
1246
1285
|
|
|
1247
1286
|
navigate: (passageName: string) => {
|
|
@@ -1596,12 +1635,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1596
1635
|
}
|
|
1597
1636
|
|
|
1598
1637
|
return reported(
|
|
1599
|
-
deletion.then(
|
|
1600
|
-
const known = await populateKnownSaves(storyData.ifid);
|
|
1601
|
-
set((state) => {
|
|
1602
|
-
state.knownSaves = known;
|
|
1603
|
-
});
|
|
1604
|
-
}),
|
|
1638
|
+
deletion.then(() => refreshKnownSaves(set, storyData.ifid)),
|
|
1605
1639
|
'spindle: failed to delete playthrough',
|
|
1606
1640
|
);
|
|
1607
1641
|
},
|
|
@@ -1672,7 +1706,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1672
1706
|
// Before anything is replaced: an incompatible payload leaves the
|
|
1673
1707
|
// running game as it is
|
|
1674
1708
|
assertPassagesExist(get().storyData, payload);
|
|
1675
|
-
|
|
1709
|
+
applyReplacement(replacement);
|
|
1676
1710
|
|
|
1677
1711
|
emit('beforeload', slot);
|
|
1678
1712
|
|
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
|
},
|