@rohal12/spindle 0.59.0 → 0.59.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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +360 -131
- package/dist/pkg/story-variables.js +21 -13
- package/package.json +1 -1
- package/src/automation/runner.ts +2 -1
- package/src/code-check.ts +11 -1
- package/src/components/PassageDialog.tsx +4 -0
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +5 -1
- package/src/components/macros/Type.tsx +3 -1
- package/src/components/macros/macro-args.ts +5 -4
- package/src/execute-mutation.ts +118 -18
- package/src/saves/save-manager.ts +4 -3
- package/src/saves/storage.ts +30 -2
- package/src/store.ts +32 -9
- package/src/story-variables.ts +38 -65
- package/src/structural.ts +212 -24
- package/src/triggers.ts +22 -2
- package/src/utils/uuid.ts +18 -0
|
@@ -10040,7 +10040,7 @@ function unset(param) {
|
|
|
10040
10040
|
return param.type === "options" ? {} : void 0;
|
|
10041
10041
|
}
|
|
10042
10042
|
var WORD_RE = /\w+/y;
|
|
10043
|
-
var
|
|
10043
|
+
var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
|
|
10044
10044
|
var WORD_OR_PUNCT_RE = /\S+/y;
|
|
10045
10045
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
10046
10046
|
function matchAt(re, src, pos) {
|
|
@@ -10049,7 +10049,8 @@ function matchAt(re, src, pos) {
|
|
|
10049
10049
|
}
|
|
10050
10050
|
/**
|
|
10051
10051
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
10052
|
-
* quoted string (with or without whitespace before it) or
|
|
10052
|
+
* quoted string (with or without whitespace before it) or number (signed,
|
|
10053
|
+
* fractional) after
|
|
10053
10054
|
* it as its value; keywords that aren't declared, and their values, are
|
|
10054
10055
|
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
10055
10056
|
* string.
|
|
@@ -10068,7 +10069,7 @@ function readOptions(params, src, { at, spans }) {
|
|
|
10068
10069
|
let j = i;
|
|
10069
10070
|
while (j < src.length && isWhitespace(src[j])) j++;
|
|
10070
10071
|
const quoted = readQuoted(src, j);
|
|
10071
|
-
const digits = quoted || j === i ? null : matchAt(
|
|
10072
|
+
const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
|
|
10072
10073
|
if (quoted) {
|
|
10073
10074
|
val = quoted.value;
|
|
10074
10075
|
i = quoted.end;
|
|
@@ -10392,15 +10393,22 @@ function* codeAndText(src, tokens, parametersOf) {
|
|
|
10392
10393
|
const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
|
|
10393
10394
|
for (let t = 0; t < tokens.length; t++) {
|
|
10394
10395
|
const token = tokens[t];
|
|
10395
|
-
if (token.type === "link")
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10396
|
+
if (token.type === "link") {
|
|
10397
|
+
yield {
|
|
10398
|
+
kind: "passage",
|
|
10399
|
+
name: token.target,
|
|
10400
|
+
offset: token.targetStart,
|
|
10401
|
+
length: token.targetEnd - token.targetStart,
|
|
10402
|
+
macro: "link",
|
|
10403
|
+
label: src.slice(token.start, token.end)
|
|
10404
|
+
};
|
|
10405
|
+
if (token.display.includes("{")) yield {
|
|
10406
|
+
kind: "text",
|
|
10407
|
+
text: token.display,
|
|
10408
|
+
offset: locate(src, token.display, token.start),
|
|
10409
|
+
where: "In the label of a link: "
|
|
10410
|
+
};
|
|
10411
|
+
} else if (token.type === "expression") yield {
|
|
10404
10412
|
kind: "code",
|
|
10405
10413
|
code: token.expression,
|
|
10406
10414
|
offset: token.end - 1 - token.expression.length,
|
|
@@ -10566,7 +10574,7 @@ function* argPieces(args, offset, params, macro, label) {
|
|
|
10566
10574
|
};
|
|
10567
10575
|
}
|
|
10568
10576
|
}
|
|
10569
|
-
yield* visit(params, values);
|
|
10577
|
+
yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
|
|
10570
10578
|
}
|
|
10571
10579
|
/**
|
|
10572
10580
|
* Index of `part` in `text` from `from` on, else anywhere, else `from`: the
|
package/package.json
CHANGED
package/src/automation/runner.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import type { StoryAPI } from '../story-api';
|
|
9
9
|
import type { StoryAction } from '../action-registry';
|
|
10
10
|
import { errorMessage } from '../utils/error-message';
|
|
11
|
+
import { deepEqual } from '../structural';
|
|
11
12
|
|
|
12
13
|
export interface RunOptions {
|
|
13
14
|
onStep?: (stepIndex: number, step: AutomationStep) => void;
|
|
@@ -88,7 +89,7 @@ async function executeStep(
|
|
|
88
89
|
if (assert.variables) {
|
|
89
90
|
for (const [key, expected] of Object.entries(assert.variables)) {
|
|
90
91
|
const actual = story.get(key);
|
|
91
|
-
if (actual
|
|
92
|
+
if (!deepEqual(actual, expected)) {
|
|
92
93
|
errors.push({
|
|
93
94
|
step: index,
|
|
94
95
|
message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
package/src/code-check.ts
CHANGED
|
@@ -249,6 +249,15 @@ export function* codeAndText(
|
|
|
249
249
|
macro: 'link',
|
|
250
250
|
label: src.slice(token.start, token.end),
|
|
251
251
|
};
|
|
252
|
+
// The label is markup too (the link renders as `{link}`)
|
|
253
|
+
if (token.display.includes('{')) {
|
|
254
|
+
yield {
|
|
255
|
+
kind: 'text',
|
|
256
|
+
text: token.display,
|
|
257
|
+
offset: locate(src, token.display, token.start),
|
|
258
|
+
where: 'In the label of a link: ',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
252
261
|
} else if (token.type === 'expression') {
|
|
253
262
|
yield {
|
|
254
263
|
kind: 'code',
|
|
@@ -435,7 +444,8 @@ export function* argPieces(
|
|
|
435
444
|
}
|
|
436
445
|
}
|
|
437
446
|
}
|
|
438
|
-
|
|
447
|
+
// Options are visited in metadata order, which is not the order written
|
|
448
|
+
yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
|
|
439
449
|
}
|
|
440
450
|
|
|
441
451
|
/**
|
|
@@ -5,6 +5,7 @@ import { renderNodes, NobrContext } from '../markup/render';
|
|
|
5
5
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
6
6
|
import { emitFromRender } from '../event-emitter';
|
|
7
7
|
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
8
|
+
import { registerOpenDialog } from '../triggers';
|
|
8
9
|
import { errorMessage } from '../utils/error-message';
|
|
9
10
|
|
|
10
11
|
export const DialogCloseContext = createContext<(() => void) | null>(null);
|
|
@@ -70,6 +71,9 @@ export function PassageDialog({
|
|
|
70
71
|
// Declared before the dialogrender effect so handlers can move focus.
|
|
71
72
|
useModalFocus(panelRef, '.dialog-body', dismissible, stableOnClose);
|
|
72
73
|
|
|
74
|
+
// Be on the stack of displayed dialogs the Story dialog API works on.
|
|
75
|
+
useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
|
|
76
|
+
|
|
73
77
|
// Signal that the dialog's DOM is committed (once per open).
|
|
74
78
|
useLayoutEffect(() => {
|
|
75
79
|
if (panelRef.current) {
|
|
@@ -16,7 +16,8 @@ defineMacro({
|
|
|
16
16
|
{ name: 'passage', type: 'passage' },
|
|
17
17
|
],
|
|
18
18
|
render({ children = [] }, ctx) {
|
|
19
|
-
|
|
19
|
+
// The label is markup: resolved once, for the element and its action
|
|
20
|
+
const display = ctx.resolve?.(ctx.args.text ?? '') ?? '';
|
|
20
21
|
let passage: string | null = null;
|
|
21
22
|
// Boxed: anything can be thrown, including null and other falsy values
|
|
22
23
|
let failure: { error: unknown } | undefined;
|
|
@@ -44,7 +44,11 @@ defineMacro({
|
|
|
44
44
|
],
|
|
45
45
|
render(_props, ctx) {
|
|
46
46
|
try {
|
|
47
|
-
const { currentExpr, maxExpr, labelMode } = meterArgs(
|
|
47
|
+
const { currentExpr, maxExpr, labelMode } = meterArgs({
|
|
48
|
+
...ctx.args,
|
|
49
|
+
// The label is markup: resolved before reading none, % or a unit
|
|
50
|
+
label: ctx.resolve?.(ctx.args.label),
|
|
51
|
+
});
|
|
48
52
|
const current = Number(ctx.evaluate!(currentExpr));
|
|
49
53
|
const max = Number(ctx.evaluate!(maxExpr));
|
|
50
54
|
const pct =
|
|
@@ -15,12 +15,14 @@ defineMacro({
|
|
|
15
15
|
const visibleCharsRef = useRef(0);
|
|
16
16
|
visibleCharsRef.current = visibleChars;
|
|
17
17
|
|
|
18
|
+
// Measure after every render: reactive content can become nonempty (or
|
|
19
|
+
// change length) after mounting. Setting an equal count is a no-op.
|
|
18
20
|
useEffect(() => {
|
|
19
21
|
if (containerRef.current) {
|
|
20
22
|
const text = containerRef.current.textContent || '';
|
|
21
23
|
setTotalChars(text.length);
|
|
22
24
|
}
|
|
23
|
-
}
|
|
25
|
+
});
|
|
24
26
|
|
|
25
27
|
// Typewriter interval
|
|
26
28
|
useEffect(() => {
|
|
@@ -108,7 +108,7 @@ function unset(param: ParameterDef): unknown {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const WORD_RE = /\w+/y;
|
|
111
|
-
const
|
|
111
|
+
const NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
|
|
112
112
|
const WORD_OR_PUNCT_RE = /\S+/y;
|
|
113
113
|
|
|
114
114
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
@@ -119,7 +119,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
122
|
-
* quoted string (with or without whitespace before it) or
|
|
122
|
+
* quoted string (with or without whitespace before it) or number (signed,
|
|
123
|
+
* fractional) after
|
|
123
124
|
* it as its value; keywords that aren't declared, and their values, are
|
|
124
125
|
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
125
126
|
* string.
|
|
@@ -141,13 +142,13 @@ function readOptions(
|
|
|
141
142
|
i += key.length;
|
|
142
143
|
|
|
143
144
|
// A value is a quoted string, which may follow the keyword directly
|
|
144
|
-
// (`goto"Hall"`: a quote can't be part of one), or a
|
|
145
|
+
// (`goto"Hall"`: a quote can't be part of one), or a number after
|
|
145
146
|
// whitespace.
|
|
146
147
|
let val: string | undefined;
|
|
147
148
|
let j = i;
|
|
148
149
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
149
150
|
const quoted = readQuoted(src, j);
|
|
150
|
-
const digits = quoted || j === i ? null : matchAt(
|
|
151
|
+
const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
|
|
151
152
|
if (quoted) {
|
|
152
153
|
val = quoted.value;
|
|
153
154
|
i = quoted.end;
|
package/src/execute-mutation.ts
CHANGED
|
@@ -10,13 +10,18 @@ import { useStoryStore } from './store';
|
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
12
|
import {
|
|
13
|
+
aliasChanges,
|
|
13
14
|
applyChange,
|
|
14
15
|
deepClone,
|
|
15
16
|
deepEqual,
|
|
16
17
|
diffPaths,
|
|
18
|
+
existingObjects,
|
|
17
19
|
isApplied,
|
|
18
20
|
isMergeable,
|
|
21
|
+
locateObjects,
|
|
19
22
|
mergeKeys,
|
|
23
|
+
pathKey,
|
|
24
|
+
underChange,
|
|
20
25
|
type PathChange,
|
|
21
26
|
} from './structural';
|
|
22
27
|
import { getByPath } from './utils/object-path';
|
|
@@ -71,6 +76,39 @@ export function readState(): StoryState {
|
|
|
71
76
|
const cloneValue = <T>(value: T): T =>
|
|
72
77
|
deepClone(value, { keepUnregistered: true });
|
|
73
78
|
|
|
79
|
+
/** Copies values for one target, so what they share they share there too. */
|
|
80
|
+
type Cloner = <T>(value: T) => T;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A cloner of values written into `target`. The references among the values
|
|
84
|
+
* it copies are kept (`$b = $a` writes one array twice: the two paths get
|
|
85
|
+
* one copy), and, given where the objects of the source are (see
|
|
86
|
+
* locateObjects), an object that is one the target holds already is that
|
|
87
|
+
* one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
|
|
88
|
+
*/
|
|
89
|
+
function clonerFor(
|
|
90
|
+
target: Record<string, unknown>,
|
|
91
|
+
paths?: ReadonlyMap<object, string[]>,
|
|
92
|
+
): Cloner {
|
|
93
|
+
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
94
|
+
return (value) => deepClone(value, { keepUnregistered: true, seen });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One cloner per target copy, made when first used. */
|
|
98
|
+
const clonersFor = (
|
|
99
|
+
paths?: (target: Record<string, unknown>) => ReadonlyMap<object, string[]>,
|
|
100
|
+
) => {
|
|
101
|
+
const made = new Map<object, Cloner>();
|
|
102
|
+
return (target: Record<string, unknown>): Cloner => {
|
|
103
|
+
let cloner = made.get(target);
|
|
104
|
+
if (!cloner) {
|
|
105
|
+
cloner = clonerFor(target, paths?.(target));
|
|
106
|
+
made.set(target, cloner);
|
|
107
|
+
}
|
|
108
|
+
return cloner;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
74
112
|
/**
|
|
75
113
|
* A frozen copy of a value read from a working copy, matching what reads
|
|
76
114
|
* from the (frozen) store return: writing to it fails instead of changing
|
|
@@ -94,6 +132,7 @@ function mirror(
|
|
|
94
132
|
ns: NamespaceName,
|
|
95
133
|
change: PathChange,
|
|
96
134
|
scopes: readonly MutationScope[] = activeScopes,
|
|
135
|
+
cloner: (target: Record<string, unknown>) => Cloner = clonerOf,
|
|
97
136
|
): void {
|
|
98
137
|
const root = change.path[0]!;
|
|
99
138
|
for (const scope of scopes) {
|
|
@@ -103,7 +142,7 @@ function mirror(
|
|
|
103
142
|
copy,
|
|
104
143
|
change.deleted
|
|
105
144
|
? change
|
|
106
|
-
: { ...change, value:
|
|
145
|
+
: { ...change, value: cloner(copy)(change.value) },
|
|
107
146
|
);
|
|
108
147
|
} catch {
|
|
109
148
|
const stored = draft[ns][root];
|
|
@@ -119,6 +158,9 @@ function mirror(
|
|
|
119
158
|
}
|
|
120
159
|
}
|
|
121
160
|
|
|
161
|
+
/** A cloner of a copy of its own (no references shared with others). */
|
|
162
|
+
const clonerOf = (target: Record<string, unknown>): Cloner => clonerFor(target);
|
|
163
|
+
|
|
122
164
|
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
123
165
|
|
|
124
166
|
/**
|
|
@@ -244,10 +286,19 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
244
286
|
own.length ? writtenPaths(view[ns], next[ns], own) : [],
|
|
245
287
|
] as const;
|
|
246
288
|
});
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
// Each target takes its own copies, which share what the written values do
|
|
290
|
+
const cloners = clonersFor();
|
|
291
|
+
const owned = (
|
|
292
|
+
change: PathChange,
|
|
293
|
+
target: Record<string, unknown>,
|
|
294
|
+
): PathChange =>
|
|
295
|
+
change.deleted
|
|
296
|
+
? change
|
|
297
|
+
: { ...change, value: cloners(target)(change.value) };
|
|
249
298
|
for (const [ns, list] of changes) {
|
|
250
|
-
for (const change of list)
|
|
299
|
+
for (const change of list) {
|
|
300
|
+
applyChange(inner.work[ns], owned(change, inner.work[ns]));
|
|
301
|
+
}
|
|
251
302
|
}
|
|
252
303
|
|
|
253
304
|
return (draft) => {
|
|
@@ -266,7 +317,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
266
317
|
for (const change of list) {
|
|
267
318
|
const root = change.path[0]!;
|
|
268
319
|
try {
|
|
269
|
-
applyChange(namespaces[ns], owned(change));
|
|
320
|
+
applyChange(namespaces[ns], owned(change, namespaces[ns]));
|
|
270
321
|
} catch {
|
|
271
322
|
if (hasOwn(inner.work[ns], root)) {
|
|
272
323
|
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
@@ -274,7 +325,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
274
325
|
delete namespaces[ns][root];
|
|
275
326
|
}
|
|
276
327
|
}
|
|
277
|
-
mirror(namespaces, ns, change);
|
|
328
|
+
mirror(namespaces, ns, change, activeScopes, cloners);
|
|
278
329
|
}
|
|
279
330
|
}
|
|
280
331
|
};
|
|
@@ -312,7 +363,7 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
312
363
|
scope,
|
|
313
364
|
keepRunning,
|
|
314
365
|
changes: NAMESPACES.map(
|
|
315
|
-
(ns) => [ns,
|
|
366
|
+
(ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
|
|
316
367
|
),
|
|
317
368
|
}));
|
|
318
369
|
const changed = all.filter(({ changes }) =>
|
|
@@ -336,35 +387,84 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
336
387
|
}
|
|
337
388
|
|
|
338
389
|
function commitRecipe(draft: VariableNamespaces): void {
|
|
339
|
-
all.forEach(({ scope,
|
|
340
|
-
|
|
390
|
+
all.forEach(({ scope, changes }, i) => {
|
|
391
|
+
// The copies the store and the enclosing mutations take are their own,
|
|
392
|
+
// and keep the references of the values written (one object written
|
|
393
|
+
// at two paths is one object there) and to the objects they hold
|
|
394
|
+
// already (see clonerFor). The code's working copy stays its own.
|
|
395
|
+
const paths = new Map(
|
|
396
|
+
changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]),
|
|
397
|
+
);
|
|
398
|
+
const own = new Map(
|
|
399
|
+
changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
|
|
400
|
+
);
|
|
401
|
+
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
402
|
+
const enclosingCloners = new Map(
|
|
403
|
+
changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns)!)]),
|
|
404
|
+
);
|
|
341
405
|
for (const [ns, list] of changes) {
|
|
342
406
|
const replaced = new Set<string>();
|
|
343
407
|
for (const change of list) {
|
|
344
408
|
const root = change.path[0]!;
|
|
345
|
-
|
|
346
|
-
if (replaced.has(root) || isApplied(draft[ns], change)) continue;
|
|
409
|
+
if (replaced.has(root)) continue;
|
|
347
410
|
try {
|
|
348
|
-
|
|
349
|
-
draft[ns],
|
|
350
|
-
|
|
351
|
-
|
|
411
|
+
if (change.deleted) {
|
|
412
|
+
if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// A changed path already holding the value keeps its reference;
|
|
416
|
+
// an alias change is one of reference only: it holds when the
|
|
417
|
+
// very object is there.
|
|
418
|
+
const value = change.alias ? own.get(ns)!(change.value) : undefined;
|
|
419
|
+
if (
|
|
420
|
+
change.alias
|
|
421
|
+
? Object.is(getByPath(draft[ns], change.path), value)
|
|
422
|
+
: isApplied(draft[ns], change)
|
|
423
|
+
) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
applyChange(draft[ns], {
|
|
427
|
+
...change,
|
|
428
|
+
value: change.alias ? value : own.get(ns)!(change.value),
|
|
429
|
+
});
|
|
352
430
|
} catch {
|
|
353
431
|
// An intermediate object the code wrote into is gone from the
|
|
354
432
|
// store: the code's view of the whole root wins.
|
|
355
|
-
draft[ns][root] = own(scope.work[ns][root]);
|
|
433
|
+
draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
|
|
356
434
|
replaced.add(root);
|
|
357
435
|
}
|
|
358
436
|
}
|
|
359
437
|
// Hand the changes to the mutations this one runs inside, before
|
|
360
438
|
// watchers fired by this update run.
|
|
361
|
-
const
|
|
362
|
-
|
|
439
|
+
for (const change of list) {
|
|
440
|
+
mirror(draft, ns, change, enclosing, enclosingCloners.get(ns)!);
|
|
441
|
+
}
|
|
363
442
|
}
|
|
364
443
|
});
|
|
365
444
|
}
|
|
366
445
|
}
|
|
367
446
|
|
|
447
|
+
/**
|
|
448
|
+
* The changes of the code to a namespace: its property paths that differ
|
|
449
|
+
* (see diffPaths), and those that hold equal content but another object
|
|
450
|
+
* than they did (see aliasChanges), those first.
|
|
451
|
+
*/
|
|
452
|
+
function changesOf(
|
|
453
|
+
base: Record<string, unknown>,
|
|
454
|
+
work: Record<string, unknown>,
|
|
455
|
+
): PathChange[] {
|
|
456
|
+
const written = diffPaths(base, work);
|
|
457
|
+
const keys = new Set(written.map((c) => pathKey(c.path)));
|
|
458
|
+
const aliases: PathChange[] = [];
|
|
459
|
+
for (const change of aliasChanges(base, work)) {
|
|
460
|
+
// Written whole by a change at it or above it
|
|
461
|
+
if (underChange(change.path, keys)) continue;
|
|
462
|
+
keys.add(pathKey(change.path));
|
|
463
|
+
aliases.push(change);
|
|
464
|
+
}
|
|
465
|
+
return [...aliases, ...written];
|
|
466
|
+
}
|
|
467
|
+
|
|
368
468
|
/** Every running mutation, to commit with its code going on. */
|
|
369
469
|
const running = (): Commit[] =>
|
|
370
470
|
activeScopes.map((scope) => ({ scope, keepRunning: true }));
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SaveExport,
|
|
8
8
|
StorageInfo,
|
|
9
9
|
} from './types';
|
|
10
|
+
import { randomUUID } from '../utils/uuid';
|
|
10
11
|
import { checkSaveExport, estimatePayloadBytes } from './types';
|
|
11
12
|
import {
|
|
12
13
|
decodePayload,
|
|
@@ -130,7 +131,7 @@ export const startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
|
130
131
|
|
|
131
132
|
async function startNewPlaythroughNow(
|
|
132
133
|
ifid: string,
|
|
133
|
-
id: string =
|
|
134
|
+
id: string = randomUUID(),
|
|
134
135
|
): Promise<string> {
|
|
135
136
|
const backend = await getBackend();
|
|
136
137
|
const num = await nextPlaythroughNumber(ifid);
|
|
@@ -296,7 +297,7 @@ async function createSaveNow(
|
|
|
296
297
|
): Promise<SaveRecord> {
|
|
297
298
|
const now = new Date().toISOString();
|
|
298
299
|
const meta: SaveMeta = {
|
|
299
|
-
id:
|
|
300
|
+
id: randomUUID(),
|
|
300
301
|
ifid,
|
|
301
302
|
playthroughId,
|
|
302
303
|
createdAt: now,
|
|
@@ -774,7 +775,7 @@ async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
|
|
|
774
775
|
|
|
775
776
|
// Re-assign a new ID to avoid collisions
|
|
776
777
|
const record = deepClone(data.save);
|
|
777
|
-
record.meta.id =
|
|
778
|
+
record.meta.id = randomUUID();
|
|
778
779
|
record.meta.updatedAt = new Date().toISOString();
|
|
779
780
|
// The save now belongs to this story: it is listed, loaded and cleared
|
|
780
781
|
// with it, whatever its own metadata says
|
package/src/saves/storage.ts
CHANGED
|
@@ -289,7 +289,16 @@ function createIDBBackend(): StorageBackend {
|
|
|
289
289
|
}
|
|
290
290
|
};
|
|
291
291
|
|
|
292
|
-
req.onsuccess = () =>
|
|
292
|
+
req.onsuccess = () => {
|
|
293
|
+
const db = req.result;
|
|
294
|
+
// Let another tab delete or upgrade the database: close at once and
|
|
295
|
+
// reopen on the next use.
|
|
296
|
+
db.onversionchange = () => {
|
|
297
|
+
db.close();
|
|
298
|
+
dbPromise = null;
|
|
299
|
+
};
|
|
300
|
+
resolve(db);
|
|
301
|
+
};
|
|
293
302
|
req.onerror = () => reject(req.error);
|
|
294
303
|
});
|
|
295
304
|
|
|
@@ -315,7 +324,20 @@ function createIDBBackend(): StorageBackend {
|
|
|
315
324
|
make: (store: IDBObjectStore) => IDBRequest<R>,
|
|
316
325
|
): Promise<R> => {
|
|
317
326
|
const db = await openDB();
|
|
318
|
-
|
|
327
|
+
const tx = db.transaction(name, mode);
|
|
328
|
+
const result = idbReq(make(tx.objectStore(name)));
|
|
329
|
+
// A request can succeed and its transaction still roll back, so a
|
|
330
|
+
// write is done only when the transaction completes.
|
|
331
|
+
if (mode === 'readonly') return result;
|
|
332
|
+
const committed = new Promise<void>((resolve, reject) => {
|
|
333
|
+
tx.oncomplete = () => resolve();
|
|
334
|
+
tx.onabort = () =>
|
|
335
|
+
reject(
|
|
336
|
+
tx.error ?? new DOMException('Transaction aborted', 'AbortError'),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
const [value] = await Promise.all([result, committed]);
|
|
340
|
+
return value;
|
|
319
341
|
};
|
|
320
342
|
|
|
321
343
|
return {
|
|
@@ -348,6 +370,12 @@ function createIDBBackend(): StorageBackend {
|
|
|
348
370
|
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
349
371
|
req.onsuccess = () => resolve();
|
|
350
372
|
req.onerror = () => reject(req.error);
|
|
373
|
+
req.onblocked = () =>
|
|
374
|
+
reject(
|
|
375
|
+
new Error(
|
|
376
|
+
'Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again.',
|
|
377
|
+
),
|
|
378
|
+
);
|
|
351
379
|
});
|
|
352
380
|
});
|
|
353
381
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { create } from './preact-store';
|
|
2
|
+
import { randomUUID } from './utils/uuid';
|
|
2
3
|
import { immer } from 'zustand/middleware/immer';
|
|
3
4
|
import type { StateCreator } from 'zustand/vanilla';
|
|
4
5
|
import {
|
|
@@ -120,7 +121,10 @@ let lastNavigationVars: Namespace = createNamespace();
|
|
|
120
121
|
* object) or deleted. A recorded value is a deep copy, so that changing a
|
|
121
122
|
* live value in place (a class instance is not frozen) cannot change
|
|
122
123
|
* history; instances of unregistered classes are kept, as the store keeps
|
|
123
|
-
* them (a save then refuses them).
|
|
124
|
+
* them (a save then refuses them). The copies are made together, so what
|
|
125
|
+
* variables share (an object, a cycle) stays shared in them, and a variable
|
|
126
|
+
* left as it is that shares with a replaced one is recorded again, to share
|
|
127
|
+
* with its copy.
|
|
124
128
|
*/
|
|
125
129
|
function computeVarPatches(
|
|
126
130
|
prev: Record<string, unknown>,
|
|
@@ -132,13 +136,32 @@ function computeVarPatches(
|
|
|
132
136
|
// whose inherited `constructor` is no variable
|
|
133
137
|
if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
|
|
134
138
|
}
|
|
139
|
+
const seen = new Map<object, object>();
|
|
140
|
+
const record = (key: string) =>
|
|
141
|
+
forward.push({
|
|
142
|
+
key,
|
|
143
|
+
deleted: false,
|
|
144
|
+
value: deepClone(curr[key], { keepUnregistered: true, seen }),
|
|
145
|
+
});
|
|
146
|
+
const unchanged: string[] = [];
|
|
135
147
|
for (const key of Object.keys(curr)) {
|
|
136
|
-
if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key]))
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
|
|
149
|
+
else if (typeof curr[key] === 'object' && curr[key] !== null) {
|
|
150
|
+
unchanged.push(key);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Probe an unchanged variable with a copy of its own: it shares with a
|
|
154
|
+
// recorded one if the copy meets an object already copied
|
|
155
|
+
for (let found = seen.size > 0; found; ) {
|
|
156
|
+
found = false;
|
|
157
|
+
for (const key of unchanged) {
|
|
158
|
+
const probe = new Map<object, object>();
|
|
159
|
+
deepClone(curr[key], { keepUnregistered: true, seen: probe });
|
|
160
|
+
if (![...probe.keys()].some((object) => seen.has(object))) continue;
|
|
161
|
+
record(key);
|
|
162
|
+
unchanged.splice(unchanged.indexOf(key), 1);
|
|
163
|
+
found = true;
|
|
164
|
+
break;
|
|
142
165
|
}
|
|
143
166
|
}
|
|
144
167
|
return { forward };
|
|
@@ -573,7 +596,7 @@ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
|
|
|
573
596
|
|
|
574
597
|
/** Move the running game to a new playthrough at once (see restart). */
|
|
575
598
|
function switchToNewPlaythrough(ifid: string): void {
|
|
576
|
-
const id =
|
|
599
|
+
const id = randomUUID();
|
|
577
600
|
const stored = startNewPlaythrough(ifid, id).catch((err) => {
|
|
578
601
|
console.error('spindle: failed to start new playthrough', err);
|
|
579
602
|
});
|
|
@@ -1462,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1462
1485
|
const ifid = storyData.ifid;
|
|
1463
1486
|
const current = knownPlaythroughId();
|
|
1464
1487
|
const established = playthroughSetup;
|
|
1465
|
-
const replacementId =
|
|
1488
|
+
const replacementId = randomUUID();
|
|
1466
1489
|
const deletion = smDeletePlaythroughData(ifid, playthroughId, {
|
|
1467
1490
|
current: current || established,
|
|
1468
1491
|
id: replacementId,
|