@rohal12/spindle 0.52.4 → 0.52.6
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 +1502 -1521
- package/dist/pkg/macro-registry.json +255 -0
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/components/macros/Button.tsx +2 -4
- package/src/components/macros/Checkbox.tsx +6 -13
- package/src/components/macros/Computed.tsx +38 -55
- package/src/components/macros/Cycle.tsx +1 -0
- package/src/components/macros/Dialog.tsx +7 -8
- package/src/components/macros/Do.tsx +2 -1
- package/src/components/macros/For.tsx +27 -78
- package/src/components/macros/Goto.tsx +6 -9
- package/src/components/macros/Include.tsx +10 -57
- package/src/components/macros/Listbox.tsx +1 -0
- package/src/components/macros/MacroError.tsx +37 -0
- package/src/components/macros/MacroLink.tsx +9 -24
- package/src/components/macros/Meter.tsx +19 -37
- package/src/components/macros/Numberbox.tsx +5 -32
- package/src/components/macros/PassageDisplay.tsx +37 -47
- package/src/components/macros/Print.tsx +1 -0
- package/src/components/macros/Radiobutton.tsx +8 -38
- package/src/components/macros/Repeat.tsx +3 -3
- package/src/components/macros/Set.tsx +3 -29
- package/src/components/macros/Switch.tsx +36 -37
- package/src/components/macros/Textarea.tsx +2 -28
- package/src/components/macros/Textbox.tsx +2 -29
- package/src/components/macros/Type.tsx +3 -3
- package/src/components/macros/Unset.tsx +17 -40
- package/src/components/macros/Watch.tsx +23 -91
- package/src/components/macros/WidgetInvocation.tsx +9 -71
- package/src/components/macros/detached-body.tsx +2 -2
- package/src/components/macros/input-macro.ts +48 -0
- package/src/components/macros/locals-scope.tsx +60 -0
- package/src/components/macros/macro-args.ts +280 -0
- package/src/components/macros/option-utils.ts +7 -12
- package/src/define-macro.ts +47 -30
- package/src/execute-mutation.ts +17 -97
- package/src/hooks/use-render-options.ts +26 -0
- package/src/markup/render.tsx +2 -4
- package/src/registry.ts +48 -0
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
- package/types/index.d.ts +69 -6
- package/types/tooling.d.ts +33 -1
package/dist/pkg/headless.js
CHANGED
|
@@ -32,28 +32,192 @@ function create() {
|
|
|
32
32
|
return (initializer) => bindPreact(createStore()(initializer));
|
|
33
33
|
}
|
|
34
34
|
//#endregion
|
|
35
|
-
//#region src/
|
|
36
|
-
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
//#region src/utils/namespace.ts
|
|
36
|
+
/** The variable name no namespace can hold. */
|
|
37
|
+
var RESERVED_NAME = "__proto__";
|
|
38
|
+
/**
|
|
39
|
+
* Why `name` (without its sigil) cannot be a variable name, or undefined if
|
|
40
|
+
* it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
|
|
41
|
+
*/
|
|
42
|
+
function variableNameError(name, label = name) {
|
|
43
|
+
return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
|
|
41
44
|
}
|
|
45
|
+
/** Throw a TypeError if `name` cannot be a variable name. */
|
|
46
|
+
function checkVariableName(name, label = name) {
|
|
47
|
+
const error = variableNameError(name, label);
|
|
48
|
+
if (error) throw new TypeError(`spindle: ${error}`);
|
|
49
|
+
}
|
|
50
|
+
/** Whether `obj` holds `key` as an own property. */
|
|
51
|
+
var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
52
|
+
/** `ns[key]` if `ns` holds it as an own property, else undefined. */
|
|
53
|
+
var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
|
|
42
54
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* "constructor" (plain data) cannot hide or fake the class.
|
|
55
|
+
* A record with no prototype holding the own entries of `sources`, or only
|
|
56
|
+
* those whose value passes `keep`.
|
|
46
57
|
*/
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
58
|
+
function ownEntries(sources, keep = () => true) {
|
|
59
|
+
const record = Object.create(null);
|
|
60
|
+
for (const source of sources) {
|
|
61
|
+
if (!source) continue;
|
|
62
|
+
for (const key of Object.keys(source)) {
|
|
63
|
+
const value = source[key];
|
|
64
|
+
if (keep(value)) record[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return record;
|
|
50
68
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
/** A namespace with no prototype holding the own entries of `sources`. */
|
|
70
|
+
var createNamespace = (...sources) => ownEntries(sources);
|
|
71
|
+
/** Whether `value` is an object with no prototype. */
|
|
72
|
+
var isNamespace = (value) => Object.getPrototypeOf(value) === null;
|
|
73
|
+
/** `ns` itself if it has no prototype, else a namespace copy of it. */
|
|
74
|
+
var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
|
|
75
|
+
/** A namespace copy of `ns` with `key` set to `value`. */
|
|
76
|
+
function withEntry(ns, key, value) {
|
|
77
|
+
const next = createNamespace(ns);
|
|
78
|
+
next[key] = value;
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
/** An empty namespace that cannot change. */
|
|
82
|
+
var EMPTY_NAMESPACE = Object.freeze(createNamespace());
|
|
83
|
+
/** The names whose own entries differ between `a` and `b`, `a`'s first. */
|
|
84
|
+
function changedNames(a, b) {
|
|
85
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
|
|
86
|
+
}
|
|
87
|
+
var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
88
|
+
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
89
|
+
function countOf(counts, name) {
|
|
90
|
+
const value = ownValue(counts, name);
|
|
91
|
+
return isCount(value) ? value : 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Counters with no prototype holding the own entries of `source` that are
|
|
95
|
+
* counts, with `name` (if given) counted once more. Immer copies the
|
|
96
|
+
* counters on every write anyway, so counting into a fresh copy costs no
|
|
97
|
+
* more than counting in place.
|
|
98
|
+
*/
|
|
99
|
+
function createCounts(source, name) {
|
|
100
|
+
const counts = ownEntries([source], isCount);
|
|
101
|
+
if (name !== void 0) counts[name] = countOf(source, name) + 1;
|
|
102
|
+
return counts;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/utils/object-path.ts
|
|
106
|
+
/**
|
|
107
|
+
* Traverse dot-path segments on an object and return the nested value.
|
|
108
|
+
* Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
|
|
109
|
+
* are not story state: unless an object holds one as its own property, it
|
|
110
|
+
* reads as missing, as setByPath() treats it. Other inherited properties
|
|
111
|
+
* (class getters, `size` of a Map) are read.
|
|
112
|
+
*/
|
|
113
|
+
function getByPath(obj, segments) {
|
|
114
|
+
let current = obj;
|
|
115
|
+
for (const seg of segments) {
|
|
116
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
117
|
+
if (seg in Object.prototype && !hasOwn(current, seg)) return void 0;
|
|
118
|
+
current = current[seg];
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
/** Array indices ("0", "1", …) and "length": the keys an array holds. */
|
|
123
|
+
var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
|
|
124
|
+
/**
|
|
125
|
+
* Built-ins whose content is not their properties: a property written into
|
|
126
|
+
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
127
|
+
*/
|
|
128
|
+
var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
|
|
129
|
+
/**
|
|
130
|
+
* Shallow copy that keeps the prototype, so a registered class instance
|
|
131
|
+
* stays an instance of its class (with the same own keys deepClone copies).
|
|
132
|
+
*/
|
|
133
|
+
function shallowCopy(value) {
|
|
134
|
+
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
135
|
+
for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
|
|
136
|
+
value: value[key],
|
|
137
|
+
enumerable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
configurable: true
|
|
140
|
+
});
|
|
141
|
+
return copy;
|
|
55
142
|
}
|
|
56
143
|
/**
|
|
144
|
+
* Set a value at dot-path `segments` below `root`.
|
|
145
|
+
*
|
|
146
|
+
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
147
|
+
* class instances and everything below them) are copied on the way down and
|
|
148
|
+
* the copy is assigned to its parent, so the write produces new references
|
|
149
|
+
* up to the root. Writing into such an object directly would mutate the
|
|
150
|
+
* instance shared with the previous state and with history, and subscribers
|
|
151
|
+
* would see no change. Outside a draft (a private working copy) the path is
|
|
152
|
+
* written in place.
|
|
153
|
+
*
|
|
154
|
+
* The path goes through own properties of plain objects, class instances
|
|
155
|
+
* and arrays (by index); anything else throws a TypeError rather than
|
|
156
|
+
* writing where no clone or save would see it: an inherited property such
|
|
157
|
+
* as a method counts as missing, and Map, Set, Date and RegExp values and
|
|
158
|
+
* other keys of arrays are refused, as is a "__proto__" segment.
|
|
159
|
+
*/
|
|
160
|
+
function setByPath(root, segments, value, options = {}) {
|
|
161
|
+
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
162
|
+
parent[segments[segments.length - 1]] = value;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Delete the property at dot-path `segments` below `root`, copying
|
|
166
|
+
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
167
|
+
* intermediate is missing or not an object, or the property is absent (or
|
|
168
|
+
* only inherited).
|
|
169
|
+
*/
|
|
170
|
+
function deleteByPath(root, segments) {
|
|
171
|
+
const last = segments[segments.length - 1];
|
|
172
|
+
checkSegments(segments);
|
|
173
|
+
let holder = root;
|
|
174
|
+
for (const seg of segments.slice(0, -1)) {
|
|
175
|
+
if (holder == null || typeof holder !== "object" || !hasOwn(holder, seg)) return;
|
|
176
|
+
holder = holder[seg];
|
|
177
|
+
}
|
|
178
|
+
if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
|
|
179
|
+
const parent = walkToParent(root, segments, false);
|
|
180
|
+
delete parent[last];
|
|
181
|
+
}
|
|
182
|
+
/** Refuse a segment that would reach a prototype instead of story state. */
|
|
183
|
+
function checkSegments(segments) {
|
|
184
|
+
if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
|
|
185
|
+
}
|
|
186
|
+
/** Throw unless `holder` can take `key` as story state (see setByPath). */
|
|
187
|
+
function checkHolder(holder, key, segments, depth) {
|
|
188
|
+
const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
|
|
189
|
+
if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Walk to the object holding the last of `segments`, copying undrafted
|
|
193
|
+
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
194
|
+
* non-object intermediate is replaced by a plain object when
|
|
195
|
+
* `createMissing` is set, and throws a TypeError otherwise.
|
|
196
|
+
*/
|
|
197
|
+
function walkToParent(root, segments, createMissing) {
|
|
198
|
+
checkSegments(segments);
|
|
199
|
+
const copyOnWrite = isDraft(root);
|
|
200
|
+
let current = root;
|
|
201
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
202
|
+
const seg = segments[i];
|
|
203
|
+
checkHolder(current, seg, segments, i);
|
|
204
|
+
let next = hasOwn(current, seg) ? current[seg] : void 0;
|
|
205
|
+
if (next == null || typeof next !== "object") {
|
|
206
|
+
if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
|
|
207
|
+
next = {};
|
|
208
|
+
current[seg] = next;
|
|
209
|
+
} else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
|
|
210
|
+
next = shallowCopy(next);
|
|
211
|
+
current[seg] = next;
|
|
212
|
+
}
|
|
213
|
+
current = next;
|
|
214
|
+
}
|
|
215
|
+
checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
|
|
216
|
+
return current;
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/structural.ts
|
|
220
|
+
/**
|
|
57
221
|
* Set an own enumerable property. A "__proto__" key is defined as an own
|
|
58
222
|
* property (as JSON.parse does) instead of replacing the prototype.
|
|
59
223
|
*/
|
|
@@ -66,6 +230,11 @@ function setOwn(target, key, value) {
|
|
|
66
230
|
});
|
|
67
231
|
else target[key] = value;
|
|
68
232
|
}
|
|
233
|
+
function isPlainObject(value) {
|
|
234
|
+
if (typeof value !== "object" || value === null) return false;
|
|
235
|
+
const proto = Object.getPrototypeOf(value);
|
|
236
|
+
return proto === Object.prototype || proto === null;
|
|
237
|
+
}
|
|
69
238
|
function deepClone(value, options = {}) {
|
|
70
239
|
const seen = /* @__PURE__ */ new Map();
|
|
71
240
|
function clone(val) {
|
|
@@ -92,15 +261,9 @@ function deepClone(value, options = {}) {
|
|
|
92
261
|
for (const v of val) copy.add(clone(v));
|
|
93
262
|
return copy;
|
|
94
263
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
|
|
99
|
-
return copy;
|
|
100
|
-
}
|
|
101
|
-
const plain = isPlainObject(val);
|
|
102
|
-
if (!plain && options.keepUnregistered) return val;
|
|
103
|
-
const copy = plain ? Object.create(Object.getPrototypeOf(obj)) : {};
|
|
264
|
+
const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
|
|
265
|
+
if (!keepsProto && options.keepUnregistered) return val;
|
|
266
|
+
const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
|
|
104
267
|
seen.set(obj, copy);
|
|
105
268
|
for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
|
|
106
269
|
return copy;
|
|
@@ -150,10 +313,156 @@ function equal(a, b, assumed) {
|
|
|
150
313
|
const bo = b;
|
|
151
314
|
const keys = Object.keys(ao);
|
|
152
315
|
if (keys.length !== Object.keys(bo).length) return false;
|
|
153
|
-
for (const key of keys) if (!hasOwn
|
|
316
|
+
for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
154
317
|
return true;
|
|
155
318
|
}
|
|
156
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Objects merged property by property: plain objects and (registered)
|
|
321
|
+
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
322
|
+
* replaced as a whole, since their elements have no stable identity to
|
|
323
|
+
* merge by (a shift moves every index).
|
|
324
|
+
*/
|
|
325
|
+
function isMergeable(value) {
|
|
326
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether changes between `a` and `b` merge key by key: both are objects of
|
|
330
|
+
* one class (see isMergeable), or, with `arrays`, both are arrays.
|
|
331
|
+
*/
|
|
332
|
+
function mergesWith(a, b, arrays) {
|
|
333
|
+
if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
|
|
334
|
+
return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The keys at which `after` differs from `before`, two values that merge
|
|
338
|
+
* (see mergesWith): keys added, deleted, or holding another value. Store
|
|
339
|
+
* updates are immutable, so a value that kept its identity is no change,
|
|
340
|
+
* and one rebuilt with equal content (mutation code commits whole values)
|
|
341
|
+
* is none either. Values that merge are reported as 'nested', to be
|
|
342
|
+
* compared key by key; they may still be equal. With `arrays`, arrays
|
|
343
|
+
* merge too: elements at the indices both hold are compared, and the
|
|
344
|
+
* elements one has beyond the other's length are added or deleted.
|
|
345
|
+
*/
|
|
346
|
+
function keyChanges(before, after, arrays) {
|
|
347
|
+
const changes = [];
|
|
348
|
+
const compare = (key, b, a) => {
|
|
349
|
+
if (Object.is(b, a)) return;
|
|
350
|
+
if (mergesWith(b, a, arrays)) changes.push({
|
|
351
|
+
key,
|
|
352
|
+
kind: "nested",
|
|
353
|
+
before: b,
|
|
354
|
+
after: a
|
|
355
|
+
});
|
|
356
|
+
else if (!deepEqual(b, a)) changes.push({
|
|
357
|
+
key,
|
|
358
|
+
kind: "changed",
|
|
359
|
+
after: a
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
if (Array.isArray(after)) {
|
|
363
|
+
const b = before;
|
|
364
|
+
const a = after;
|
|
365
|
+
for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
|
|
366
|
+
for (let i = b.length; i < a.length; i++) changes.push({
|
|
367
|
+
key: String(i),
|
|
368
|
+
kind: "added",
|
|
369
|
+
after: a[i]
|
|
370
|
+
});
|
|
371
|
+
for (let i = a.length; i < b.length; i++) changes.push({
|
|
372
|
+
key: String(i),
|
|
373
|
+
kind: "deleted"
|
|
374
|
+
});
|
|
375
|
+
return changes;
|
|
376
|
+
}
|
|
377
|
+
for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
|
|
378
|
+
else changes.push({
|
|
379
|
+
key,
|
|
380
|
+
kind: "added",
|
|
381
|
+
after: after[key]
|
|
382
|
+
});
|
|
383
|
+
for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
|
|
384
|
+
key,
|
|
385
|
+
kind: "deleted"
|
|
386
|
+
});
|
|
387
|
+
return changes;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The property paths where `after` differs from `before`, two objects of
|
|
391
|
+
* one class: objects that merge (see isMergeable) are compared property by
|
|
392
|
+
* property, any other value as a whole.
|
|
393
|
+
*/
|
|
394
|
+
function diffPaths(before, after) {
|
|
395
|
+
const changes = [];
|
|
396
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
397
|
+
(function walk(b, a, path) {
|
|
398
|
+
if (ancestors.has(a)) return;
|
|
399
|
+
ancestors.add(a);
|
|
400
|
+
for (const change of keyChanges(b, a, false)) {
|
|
401
|
+
const at = [...path, change.key];
|
|
402
|
+
if (change.kind === "nested") walk(change.before, change.after, at);
|
|
403
|
+
else changes.push(change.kind === "deleted" ? {
|
|
404
|
+
path: at,
|
|
405
|
+
deleted: true
|
|
406
|
+
} : {
|
|
407
|
+
path: at,
|
|
408
|
+
deleted: false,
|
|
409
|
+
value: change.after
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
ancestors.delete(a);
|
|
413
|
+
})(before, after, []);
|
|
414
|
+
return changes;
|
|
415
|
+
}
|
|
416
|
+
/** Whether `target` already holds what `change` would write. */
|
|
417
|
+
function isApplied(target, change) {
|
|
418
|
+
const parent = getByPath(target, change.path.slice(0, -1));
|
|
419
|
+
if (parent === null || typeof parent !== "object") return change.deleted;
|
|
420
|
+
const key = change.path[change.path.length - 1];
|
|
421
|
+
if (!hasOwn(parent, key)) return change.deleted;
|
|
422
|
+
return !change.deleted && deepEqual(parent[key], change.value);
|
|
423
|
+
}
|
|
424
|
+
function applyChange(target, change) {
|
|
425
|
+
if (change.deleted) deleteByPath(target, change.path);
|
|
426
|
+
else setByPath(target, change.path, change.value);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Write how `after` differs from `before` (see keyChanges; arrays merge by
|
|
430
|
+
* index) into `target`, another version of the same object or array.
|
|
431
|
+
* Values that changed are written whole, as deep copies; for values that
|
|
432
|
+
* merge, `mergeNested` may merge them into `target` instead, returning
|
|
433
|
+
* whether it did. Into an array, elements removed from the end are removed
|
|
434
|
+
* at the same indices and elements added are appended, and changes at
|
|
435
|
+
* indices `target` lacks are dropped: where `target` was resized, its
|
|
436
|
+
* indices do not line up with `before`'s.
|
|
437
|
+
*/
|
|
438
|
+
function mergeKeys(target, before, after, mergeNested) {
|
|
439
|
+
const list = Array.isArray(target) ? target : void 0;
|
|
440
|
+
for (const change of keyChanges(before, after, true)) {
|
|
441
|
+
const { key } = change;
|
|
442
|
+
if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
|
|
443
|
+
else delete target[key];
|
|
444
|
+
else if (list && change.kind === "added") list.push(deepClone(change.after));
|
|
445
|
+
else if (list && Number(key) >= list.length) continue;
|
|
446
|
+
else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/class-registry.ts
|
|
451
|
+
var registry$1 = /* @__PURE__ */ new Map();
|
|
452
|
+
var ctorToName = /* @__PURE__ */ new Map();
|
|
453
|
+
function registerClass(name, ctor) {
|
|
454
|
+
registry$1.set(name, ctor);
|
|
455
|
+
ctorToName.set(ctor, name);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The registered name of the class `value` is an instance of, or undefined.
|
|
459
|
+
* Looks the constructor up on the prototype, so an own property named
|
|
460
|
+
* "constructor" (plain data) cannot hide or fake the class.
|
|
461
|
+
*/
|
|
462
|
+
function registeredClassName(value) {
|
|
463
|
+
const proto = Object.getPrototypeOf(value);
|
|
464
|
+
return proto === null ? void 0 : ctorToName.get(proto.constructor);
|
|
465
|
+
}
|
|
157
466
|
var CLASS_TAG = "__spindle_class__";
|
|
158
467
|
var DATA_TAG = "__spindle_data__";
|
|
159
468
|
/** A tagged value, as serialize() writes and deserialize() reads it. */
|
|
@@ -225,7 +534,7 @@ function serialize(value) {
|
|
|
225
534
|
const name = registeredClassName(val);
|
|
226
535
|
if (name !== void 0) return tagged(name, serKeys(val));
|
|
227
536
|
const data = serKeys(val);
|
|
228
|
-
return hasOwn
|
|
537
|
+
return hasOwn(val, CLASS_TAG) ? tagged("__Object__", data) : data;
|
|
229
538
|
}
|
|
230
539
|
return ser(value);
|
|
231
540
|
}
|
|
@@ -311,7 +620,7 @@ function isDeserializable(value) {
|
|
|
311
620
|
case "__Map__": return Array.isArray(data.entries) && Array.from(data.entries).every((entry) => Array.isArray(entry) && entry.length === 2 && check(entry[0]) && check(entry[1]));
|
|
312
621
|
case "__Set__": return Array.isArray(data.entries) && Array.from(data.entries).every(check);
|
|
313
622
|
case "__Undefined__": return true;
|
|
314
|
-
case "__Number__": return typeof data.value === "string" && hasOwn
|
|
623
|
+
case "__Number__": return typeof data.value === "string" && hasOwn(SPECIAL_NUMBERS, data.value);
|
|
315
624
|
case "__BigInt__": return typeof data.value === "string" && /^-?\d+$/.test(data.value);
|
|
316
625
|
default: return checkKeys(data);
|
|
317
626
|
}
|
|
@@ -382,6 +691,10 @@ function isSavePayload(value) {
|
|
|
382
691
|
if (typeof obj.historyIndex !== "number") return false;
|
|
383
692
|
return true;
|
|
384
693
|
}
|
|
694
|
+
/** Estimated byte size of a serialized save payload. */
|
|
695
|
+
function estimatePayloadBytes(payload) {
|
|
696
|
+
return JSON.stringify(payload).length;
|
|
697
|
+
}
|
|
385
698
|
//#endregion
|
|
386
699
|
//#region src/js-lexer.ts
|
|
387
700
|
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
@@ -2620,76 +2933,6 @@ function getPRNGPull() {
|
|
|
2620
2933
|
return currentPull;
|
|
2621
2934
|
}
|
|
2622
2935
|
//#endregion
|
|
2623
|
-
//#region src/utils/namespace.ts
|
|
2624
|
-
/** The variable name no namespace can hold. */
|
|
2625
|
-
var RESERVED_NAME = "__proto__";
|
|
2626
|
-
/**
|
|
2627
|
-
* Why `name` (without its sigil) cannot be a variable name, or undefined if
|
|
2628
|
-
* it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
|
|
2629
|
-
*/
|
|
2630
|
-
function variableNameError(name, label = name) {
|
|
2631
|
-
return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
|
|
2632
|
-
}
|
|
2633
|
-
/** Throw a TypeError if `name` cannot be a variable name. */
|
|
2634
|
-
function checkVariableName(name, label = name) {
|
|
2635
|
-
const error = variableNameError(name, label);
|
|
2636
|
-
if (error) throw new TypeError(`spindle: ${error}`);
|
|
2637
|
-
}
|
|
2638
|
-
/** Whether `obj` holds `key` as an own property. */
|
|
2639
|
-
var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
2640
|
-
/** `ns[key]` if `ns` holds it as an own property, else undefined. */
|
|
2641
|
-
var ownValue = (ns, key) => ns && hasOwn$2(ns, key) ? ns[key] : void 0;
|
|
2642
|
-
/**
|
|
2643
|
-
* A record with no prototype holding the own entries of `sources`, or only
|
|
2644
|
-
* those whose value passes `keep`.
|
|
2645
|
-
*/
|
|
2646
|
-
function ownEntries(sources, keep = () => true) {
|
|
2647
|
-
const record = Object.create(null);
|
|
2648
|
-
for (const source of sources) {
|
|
2649
|
-
if (!source) continue;
|
|
2650
|
-
for (const key of Object.keys(source)) {
|
|
2651
|
-
const value = source[key];
|
|
2652
|
-
if (keep(value)) record[key] = value;
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
return record;
|
|
2656
|
-
}
|
|
2657
|
-
/** A namespace with no prototype holding the own entries of `sources`. */
|
|
2658
|
-
var createNamespace = (...sources) => ownEntries(sources);
|
|
2659
|
-
/** Whether `value` is an object with no prototype. */
|
|
2660
|
-
var isNamespace = (value) => Object.getPrototypeOf(value) === null;
|
|
2661
|
-
/** `ns` itself if it has no prototype, else a namespace copy of it. */
|
|
2662
|
-
var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
|
|
2663
|
-
/** A namespace copy of `ns` with `key` set to `value`. */
|
|
2664
|
-
function withEntry(ns, key, value) {
|
|
2665
|
-
const next = createNamespace(ns);
|
|
2666
|
-
next[key] = value;
|
|
2667
|
-
return next;
|
|
2668
|
-
}
|
|
2669
|
-
/** An empty namespace that cannot change. */
|
|
2670
|
-
var EMPTY_NAMESPACE = Object.freeze(createNamespace());
|
|
2671
|
-
/** The names whose own entries differ between `a` and `b`, `a`'s first. */
|
|
2672
|
-
function changedNames(a, b) {
|
|
2673
|
-
return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
|
|
2674
|
-
}
|
|
2675
|
-
var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
2676
|
-
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
2677
|
-
function countOf(counts, name) {
|
|
2678
|
-
const value = ownValue(counts, name);
|
|
2679
|
-
return isCount(value) ? value : 0;
|
|
2680
|
-
}
|
|
2681
|
-
/**
|
|
2682
|
-
* Counters with no prototype holding the own entries of `source` that are
|
|
2683
|
-
* counts, with `name` (if given) counted once more. Immer copies the
|
|
2684
|
-
* counters on every write anyway, so counting into a fresh copy costs no
|
|
2685
|
-
* more than counting in place.
|
|
2686
|
-
*/
|
|
2687
|
-
function createCounts(source, name) {
|
|
2688
|
-
const counts = ownEntries([source], isCount);
|
|
2689
|
-
if (name !== void 0) counts[name] = countOf(source, name) + 1;
|
|
2690
|
-
return counts;
|
|
2691
|
-
}
|
|
2692
|
-
//#endregion
|
|
2693
2936
|
//#region src/expression.ts
|
|
2694
2937
|
var FN_CACHE_MAX = 500;
|
|
2695
2938
|
var fnCache = /* @__PURE__ */ new Map();
|
|
@@ -3436,11 +3679,29 @@ function ExprDisplay({ expression, className, id }) {
|
|
|
3436
3679
|
return /* @__PURE__ */ jsx(Fragment$1, { children: display });
|
|
3437
3680
|
}
|
|
3438
3681
|
//#endregion
|
|
3439
|
-
//#region src/
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3682
|
+
//#region src/hooks/use-render-options.ts
|
|
3683
|
+
/**
|
|
3684
|
+
* The renderNodes() options of the enclosing content: nobr, inline and raw
|
|
3685
|
+
* rendering and the locals in scope. A macro or widget body rendered with
|
|
3686
|
+
* them renders as the content around it does.
|
|
3687
|
+
*/
|
|
3688
|
+
function useRenderOptions() {
|
|
3689
|
+
return {
|
|
3690
|
+
nobr: useContext(NobrContext),
|
|
3691
|
+
inline: useContext(InlineContext),
|
|
3692
|
+
raw: useContext(RawTextContext),
|
|
3693
|
+
locals: useContext(LocalsValuesContext)
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
//#endregion
|
|
3697
|
+
//#region src/components/macros/locals-scope.tsx
|
|
3698
|
+
/**
|
|
3699
|
+
* Render `body` in a new locals scope: the locals of `parentValues`, with
|
|
3700
|
+
* `ownKeys` (an iteration's loop variables, a widget's parameters) on top.
|
|
3701
|
+
* Locals the body assigns are kept in this scope.
|
|
3702
|
+
*/
|
|
3703
|
+
function LocalsScope({ body, parentValues, ownKeys }) {
|
|
3704
|
+
const options = useRenderOptions();
|
|
3444
3705
|
const [localMutations, setLocalMutations] = useState({});
|
|
3445
3706
|
const localState = useMemo(() => createNamespace(parentValues, ownKeys, localMutations), [
|
|
3446
3707
|
parentValues,
|
|
@@ -3467,29 +3728,22 @@ function WidgetBody({ body, parentValues, ownKeys }) {
|
|
|
3467
3728
|
children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
|
|
3468
3729
|
value: localState,
|
|
3469
3730
|
children: renderNodes(body, {
|
|
3470
|
-
|
|
3471
|
-
inline,
|
|
3472
|
-
raw,
|
|
3731
|
+
...options,
|
|
3473
3732
|
locals: localState
|
|
3474
3733
|
})
|
|
3475
3734
|
})
|
|
3476
3735
|
});
|
|
3477
3736
|
}
|
|
3737
|
+
//#endregion
|
|
3738
|
+
//#region src/components/macros/WidgetInvocation.tsx
|
|
3478
3739
|
function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
|
|
3479
|
-
const
|
|
3480
|
-
const
|
|
3481
|
-
const inline = useContext(InlineContext);
|
|
3482
|
-
const raw = useContext(RawTextContext);
|
|
3740
|
+
const renderOptions = useRenderOptions();
|
|
3741
|
+
const parentValues = renderOptions.locals;
|
|
3483
3742
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
|
|
3484
3743
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
3485
3744
|
if (params.length === 0) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
|
|
3486
3745
|
value: childrenValue,
|
|
3487
|
-
children: renderNodes(body,
|
|
3488
|
-
nobr,
|
|
3489
|
-
inline,
|
|
3490
|
-
raw,
|
|
3491
|
-
locals: parentValues
|
|
3492
|
-
})
|
|
3746
|
+
children: renderNodes(body, renderOptions)
|
|
3493
3747
|
});
|
|
3494
3748
|
const argExprs = rawArgs ? splitArgs(rawArgs) : [];
|
|
3495
3749
|
const values = [];
|
|
@@ -3510,7 +3764,7 @@ function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
|
|
|
3510
3764
|
}, values);
|
|
3511
3765
|
return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
|
|
3512
3766
|
value: childrenValue,
|
|
3513
|
-
children: /* @__PURE__ */ jsx(
|
|
3767
|
+
children: /* @__PURE__ */ jsx(LocalsScope, {
|
|
3514
3768
|
body,
|
|
3515
3769
|
parentValues,
|
|
3516
3770
|
ownKeys
|
|
@@ -3715,6 +3969,32 @@ function MacroError({ macro, error }) {
|
|
|
3715
3969
|
children: `{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`
|
|
3716
3970
|
});
|
|
3717
3971
|
}
|
|
3972
|
+
/** Log an error in the macro `{label}` (its name, or name and arguments). */
|
|
3973
|
+
function logMacroError(label, error) {
|
|
3974
|
+
console.error(`spindle: Error in {${label}}${currentSourceLocation()}:`, error);
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Run `effect` once, during the macro's first render. If it throws, the
|
|
3978
|
+
* error is logged under `{macro rawArgs}` and shown in place of the macro
|
|
3979
|
+
* from then on; otherwise the macro renders nothing.
|
|
3980
|
+
*/
|
|
3981
|
+
function useRunOnce(macro, rawArgs, effect) {
|
|
3982
|
+
const failure = useRef();
|
|
3983
|
+
if (failure.current === void 0) {
|
|
3984
|
+
failure.current = null;
|
|
3985
|
+
try {
|
|
3986
|
+
effect();
|
|
3987
|
+
} catch (error) {
|
|
3988
|
+
failure.current = { error };
|
|
3989
|
+
logMacroError(`${macro} ${rawArgs}`, error);
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
if (!failure.current) return null;
|
|
3993
|
+
return /* @__PURE__ */ jsx(MacroError, {
|
|
3994
|
+
macro,
|
|
3995
|
+
error: failure.current.error
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3718
3998
|
//#endregion
|
|
3719
3999
|
//#region src/markup/render.tsx
|
|
3720
4000
|
var defaultUpdater = {
|
|
@@ -4084,11 +4364,8 @@ function resolveAttributeValue(name, value, scope, errors) {
|
|
|
4084
4364
|
}
|
|
4085
4365
|
function HtmlNodeRenderer({ node }) {
|
|
4086
4366
|
const scope = useTextScope();
|
|
4087
|
-
const nobr =
|
|
4088
|
-
const locals = useContext(LocalsValuesContext);
|
|
4089
|
-
const inRaw = useContext(RawTextContext);
|
|
4367
|
+
const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
|
|
4090
4368
|
const inSvg = useContext(SvgContext);
|
|
4091
|
-
const parentInline = useContext(InlineContext);
|
|
4092
4369
|
const tag = node.tag.toLowerCase();
|
|
4093
4370
|
const isSvgRoot = tag === "svg";
|
|
4094
4371
|
const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
|
|
@@ -4322,6 +4599,54 @@ function renderNodes(nodes, options) {
|
|
|
4322
4599
|
}
|
|
4323
4600
|
//#endregion
|
|
4324
4601
|
//#region src/saves/storage.ts
|
|
4602
|
+
/** A backend's tables, made by its `table` function. */
|
|
4603
|
+
function makeTables(table) {
|
|
4604
|
+
return {
|
|
4605
|
+
saves: table("saves", {
|
|
4606
|
+
ifid: (r) => r.meta.ifid,
|
|
4607
|
+
playthroughId: (r) => r.meta.playthroughId
|
|
4608
|
+
}),
|
|
4609
|
+
playthroughs: table("playthroughs", { ifid: (r) => r.ifid }),
|
|
4610
|
+
meta: table("meta", {})
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
function createBackend(type, { saves, playthroughs, meta }, destroy) {
|
|
4614
|
+
/** Delete the records `field` finds, resolving to their keys. */
|
|
4615
|
+
async function deleteFound(table, keyOf, field, value) {
|
|
4616
|
+
const keys = (await table.find(field, value)).map(keyOf);
|
|
4617
|
+
for (const key of keys) await table.delete(key);
|
|
4618
|
+
return keys;
|
|
4619
|
+
}
|
|
4620
|
+
async function deleteMetaWhere(test) {
|
|
4621
|
+
for (const key of await meta.keys()) if (test(key)) await meta.delete(key);
|
|
4622
|
+
}
|
|
4623
|
+
const saveId = (r) => r.meta.id;
|
|
4624
|
+
const playthroughId = (r) => r.id;
|
|
4625
|
+
return {
|
|
4626
|
+
type,
|
|
4627
|
+
putSave: (record) => saves.put(record.meta.id, record),
|
|
4628
|
+
getSave: (id) => saves.get(id),
|
|
4629
|
+
deleteSave: (id) => saves.delete(id),
|
|
4630
|
+
getSavesByIfid: (ifid) => saves.find("ifid", ifid),
|
|
4631
|
+
deleteSavesByIfid: async (ifid) => {
|
|
4632
|
+
await deleteFound(saves, saveId, "ifid", ifid);
|
|
4633
|
+
},
|
|
4634
|
+
deleteSavesByPlaythrough: (id) => deleteFound(saves, saveId, "playthroughId", id),
|
|
4635
|
+
putPlaythrough: (record) => playthroughs.put(record.id, record),
|
|
4636
|
+
getPlaythroughsByIfid: (ifid) => playthroughs.find("ifid", ifid),
|
|
4637
|
+
deletePlaythroughsByIfid: async (ifid) => {
|
|
4638
|
+
await deleteFound(playthroughs, playthroughId, "ifid", ifid);
|
|
4639
|
+
},
|
|
4640
|
+
deletePlaythroughById: (id) => playthroughs.delete(id),
|
|
4641
|
+
getMeta: (key) => meta.get(key),
|
|
4642
|
+
setMeta: (key, value) => meta.put(key, value),
|
|
4643
|
+
deleteMeta: (key) => meta.delete(key),
|
|
4644
|
+
deleteMetaByPrefix: (prefix) => deleteMetaWhere((key) => key.startsWith(prefix)),
|
|
4645
|
+
deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
|
|
4646
|
+
getAllMetaKeys: () => meta.keys(),
|
|
4647
|
+
destroy
|
|
4648
|
+
};
|
|
4649
|
+
}
|
|
4325
4650
|
/**
|
|
4326
4651
|
* In-memory storage. Like the other backends it stores copies: records and
|
|
4327
4652
|
* values go in and come out cloned, so changing a record after storing it,
|
|
@@ -4329,87 +4654,37 @@ function renderNodes(nodes, options) {
|
|
|
4329
4654
|
*/
|
|
4330
4655
|
function createMemoryBackend() {
|
|
4331
4656
|
const copy = (value) => structuredClone(value);
|
|
4332
|
-
const
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
}
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
for (const id of [...saves.keys()]) {
|
|
4352
|
-
const s = saves.get(id);
|
|
4353
|
-
if (s && s.meta.ifid === ifid) saves.delete(id);
|
|
4354
|
-
}
|
|
4355
|
-
},
|
|
4356
|
-
async deleteSavesByPlaythrough(playthroughId) {
|
|
4357
|
-
const deleted = [];
|
|
4358
|
-
for (const id of [...saves.keys()]) {
|
|
4359
|
-
const s = saves.get(id);
|
|
4360
|
-
if (s && s.meta.playthroughId === playthroughId) {
|
|
4361
|
-
saves.delete(id);
|
|
4362
|
-
deleted.push(id);
|
|
4363
|
-
}
|
|
4364
|
-
}
|
|
4365
|
-
return deleted;
|
|
4366
|
-
},
|
|
4367
|
-
async putPlaythrough(record) {
|
|
4368
|
-
playthroughs.set(record.id, copy(record));
|
|
4369
|
-
},
|
|
4370
|
-
async getPlaythroughsByIfid(ifid) {
|
|
4371
|
-
return [...playthroughs.values()].filter((p) => p.ifid === ifid).map(copy);
|
|
4372
|
-
},
|
|
4373
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4374
|
-
for (const id of [...playthroughs.keys()]) {
|
|
4375
|
-
const p = playthroughs.get(id);
|
|
4376
|
-
if (p && p.ifid === ifid) playthroughs.delete(id);
|
|
4377
|
-
}
|
|
4378
|
-
},
|
|
4379
|
-
async deletePlaythroughById(id) {
|
|
4380
|
-
playthroughs.delete(id);
|
|
4381
|
-
},
|
|
4382
|
-
async getMeta(key) {
|
|
4383
|
-
return copy(meta.get(key));
|
|
4384
|
-
},
|
|
4385
|
-
async setMeta(key, value) {
|
|
4386
|
-
meta.set(key, copy(value));
|
|
4387
|
-
},
|
|
4388
|
-
async deleteMeta(key) {
|
|
4389
|
-
meta.delete(key);
|
|
4390
|
-
},
|
|
4391
|
-
async deleteMetaByPrefix(prefix) {
|
|
4392
|
-
for (const key of [...meta.keys()]) if (key.startsWith(prefix)) meta.delete(key);
|
|
4393
|
-
},
|
|
4394
|
-
async deleteMetaByIfid(ifid) {
|
|
4395
|
-
for (const key of [...meta.keys()]) if (key.includes(ifid)) meta.delete(key);
|
|
4396
|
-
},
|
|
4397
|
-
async getAllMetaKeys() {
|
|
4398
|
-
return [...meta.keys()];
|
|
4399
|
-
},
|
|
4400
|
-
async destroy() {
|
|
4401
|
-
saves.clear();
|
|
4402
|
-
playthroughs.clear();
|
|
4403
|
-
meta.clear();
|
|
4404
|
-
}
|
|
4405
|
-
};
|
|
4657
|
+
const maps = [];
|
|
4658
|
+
function table(_name, indexes) {
|
|
4659
|
+
const rows = /* @__PURE__ */ new Map();
|
|
4660
|
+
maps.push(rows);
|
|
4661
|
+
return {
|
|
4662
|
+
get: async (key) => copy(rows.get(key)),
|
|
4663
|
+
put: async (key, value) => {
|
|
4664
|
+
rows.set(key, copy(value));
|
|
4665
|
+
},
|
|
4666
|
+
delete: async (key) => {
|
|
4667
|
+
rows.delete(key);
|
|
4668
|
+
},
|
|
4669
|
+
find: async (field, value) => [...rows.values()].filter((r) => indexes[field](r) === value).map(copy),
|
|
4670
|
+
keys: async () => [...rows.keys()]
|
|
4671
|
+
};
|
|
4672
|
+
}
|
|
4673
|
+
return createBackend("memory", makeTables(table), async () => {
|
|
4674
|
+
for (const rows of maps) rows.clear();
|
|
4675
|
+
});
|
|
4406
4676
|
}
|
|
4407
|
-
|
|
4408
|
-
var
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4677
|
+
/** Key prefixes: of the records, and of each index's ID lists. */
|
|
4678
|
+
var LS_PREFIXES = {
|
|
4679
|
+
saves: "spindle.save.",
|
|
4680
|
+
playthroughs: "spindle.pt.",
|
|
4681
|
+
meta: "spindle.meta."
|
|
4682
|
+
};
|
|
4683
|
+
var LS_INDEX_PREFIXES = {
|
|
4684
|
+
"saves.ifid": "spindle.idx.saves.",
|
|
4685
|
+
"saves.playthroughId": "spindle.idx.saves-pt.",
|
|
4686
|
+
"playthroughs.ifid": "spindle.idx.pt."
|
|
4687
|
+
};
|
|
4413
4688
|
function lsGet(key) {
|
|
4414
4689
|
const raw = localStorage.getItem(key);
|
|
4415
4690
|
if (raw === null) return void 0;
|
|
@@ -4435,107 +4710,55 @@ function lsIndexAdd(key, entry) {
|
|
|
4435
4710
|
lsSet(key, arr);
|
|
4436
4711
|
}
|
|
4437
4712
|
}
|
|
4713
|
+
/** Remove an ID from an index; an index left empty is removed. */
|
|
4438
4714
|
function lsIndexRemove(key, entry) {
|
|
4439
|
-
|
|
4715
|
+
const arr = lsIndex(key).filter((e) => e !== entry);
|
|
4716
|
+
if (arr.length > 0) lsSet(key, arr);
|
|
4717
|
+
else lsDel(key);
|
|
4440
4718
|
}
|
|
4719
|
+
/** The keys of every stored item that start with `prefix`. */
|
|
4720
|
+
var lsKeys = (prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix));
|
|
4441
4721
|
function createLocalStorageBackend() {
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
const ids = lsIndex(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
|
|
4483
|
-
for (const id of ids) {
|
|
4484
|
-
const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
|
|
4485
|
-
if (record) lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
|
|
4486
|
-
lsDel(`${LS_SAVE_PREFIX}${id}`);
|
|
4487
|
-
}
|
|
4488
|
-
lsDel(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
|
|
4489
|
-
return ids;
|
|
4490
|
-
},
|
|
4491
|
-
async putPlaythrough(record) {
|
|
4492
|
-
lsSet(`${LS_PT_PREFIX}${record.id}`, record);
|
|
4493
|
-
lsIndexAdd(`${LS_IDX_PT_PREFIX}${record.ifid}`, record.id);
|
|
4494
|
-
},
|
|
4495
|
-
async getPlaythroughsByIfid(ifid) {
|
|
4496
|
-
const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4497
|
-
const results = [];
|
|
4498
|
-
for (const id of ids) {
|
|
4499
|
-
const record = lsGet(`${LS_PT_PREFIX}${id}`);
|
|
4500
|
-
if (record) results.push(record);
|
|
4501
|
-
}
|
|
4502
|
-
return results;
|
|
4503
|
-
},
|
|
4504
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4505
|
-
const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4506
|
-
for (const id of ids) lsDel(`${LS_PT_PREFIX}${id}`);
|
|
4507
|
-
lsDel(`${LS_IDX_PT_PREFIX}${ifid}`);
|
|
4508
|
-
},
|
|
4509
|
-
async deletePlaythroughById(id) {
|
|
4510
|
-
const record = lsGet(`${LS_PT_PREFIX}${id}`);
|
|
4511
|
-
if (record) lsIndexRemove(`${LS_IDX_PT_PREFIX}${record.ifid}`, id);
|
|
4512
|
-
lsDel(`${LS_PT_PREFIX}${id}`);
|
|
4513
|
-
},
|
|
4514
|
-
async getMeta(key) {
|
|
4515
|
-
return lsGet(`${LS_META_PREFIX}${key}`);
|
|
4516
|
-
},
|
|
4517
|
-
async setMeta(key, value) {
|
|
4518
|
-
lsSet(`${LS_META_PREFIX}${key}`, value);
|
|
4519
|
-
},
|
|
4520
|
-
async deleteMeta(key) {
|
|
4521
|
-
lsDel(`${LS_META_PREFIX}${key}`);
|
|
4522
|
-
},
|
|
4523
|
-
async deleteMetaByPrefix(prefix) {
|
|
4524
|
-
const fullPrefix = `${LS_META_PREFIX}${prefix}`;
|
|
4525
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(fullPrefix)) localStorage.removeItem(key);
|
|
4526
|
-
},
|
|
4527
|
-
async deleteMetaByIfid(ifid) {
|
|
4528
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX) && key.includes(ifid)) localStorage.removeItem(key);
|
|
4529
|
-
},
|
|
4530
|
-
async getAllMetaKeys() {
|
|
4531
|
-
const keys = [];
|
|
4532
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX)) keys.push(key.slice(13));
|
|
4533
|
-
return keys;
|
|
4534
|
-
},
|
|
4535
|
-
async destroy() {
|
|
4536
|
-
for (const key of Object.keys(localStorage)) if (key.startsWith("spindle.")) localStorage.removeItem(key);
|
|
4537
|
-
}
|
|
4538
|
-
};
|
|
4722
|
+
function table(name, fields) {
|
|
4723
|
+
const prefix = LS_PREFIXES[name];
|
|
4724
|
+
const indexes = Object.entries(fields).map(([field, read]) => ({
|
|
4725
|
+
field,
|
|
4726
|
+
read,
|
|
4727
|
+
prefix: LS_INDEX_PREFIXES[`${name}.${field}`]
|
|
4728
|
+
}));
|
|
4729
|
+
/** The index lists `record` is in. */
|
|
4730
|
+
const listsOf = (record) => indexes.map((index) => `${index.prefix}${index.read(record)}`);
|
|
4731
|
+
return {
|
|
4732
|
+
get: async (key) => lsGet(`${prefix}${key}`),
|
|
4733
|
+
async put(key, value) {
|
|
4734
|
+
const previous = lsGet(`${prefix}${key}`);
|
|
4735
|
+
const lists = listsOf(value);
|
|
4736
|
+
if (previous !== void 0) {
|
|
4737
|
+
for (const list of listsOf(previous)) if (!lists.includes(list)) lsIndexRemove(list, key);
|
|
4738
|
+
}
|
|
4739
|
+
lsSet(`${prefix}${key}`, value);
|
|
4740
|
+
for (const list of lists) lsIndexAdd(list, key);
|
|
4741
|
+
},
|
|
4742
|
+
async delete(key) {
|
|
4743
|
+
const record = lsGet(`${prefix}${key}`);
|
|
4744
|
+
if (record !== void 0) for (const list of listsOf(record)) lsIndexRemove(list, key);
|
|
4745
|
+
lsDel(`${prefix}${key}`);
|
|
4746
|
+
},
|
|
4747
|
+
async find(field, value) {
|
|
4748
|
+
const index = indexes.find((i) => i.field === field);
|
|
4749
|
+
const results = [];
|
|
4750
|
+
for (const id of lsIndex(`${index.prefix}${value}`)) {
|
|
4751
|
+
const record = lsGet(`${prefix}${id}`);
|
|
4752
|
+
if (record !== void 0) results.push(record);
|
|
4753
|
+
}
|
|
4754
|
+
return results;
|
|
4755
|
+
},
|
|
4756
|
+
keys: async () => lsKeys(prefix).map((key) => key.slice(prefix.length))
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
return createBackend("localstorage", makeTables(table), async () => {
|
|
4760
|
+
for (const key of lsKeys("spindle.")) lsDel(key);
|
|
4761
|
+
});
|
|
4539
4762
|
}
|
|
4540
4763
|
var IDB_DB_NAME = "spindle";
|
|
4541
4764
|
var IDB_DB_VERSION = 1;
|
|
@@ -4559,96 +4782,52 @@ function createIDBBackend() {
|
|
|
4559
4782
|
req.onerror = () => reject(req.error);
|
|
4560
4783
|
});
|
|
4561
4784
|
return dbPromise;
|
|
4562
|
-
}
|
|
4563
|
-
function
|
|
4564
|
-
return
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
}
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
}
|
|
4607
|
-
|
|
4608
|
-
return getAllFromIndex("playthroughs", "ifid", ifid);
|
|
4609
|
-
},
|
|
4610
|
-
async deletePlaythroughsByIfid(ifid) {
|
|
4611
|
-
const records = await getAllFromIndex("playthroughs", "ifid", ifid);
|
|
4612
|
-
const store = await tx("playthroughs", "readwrite");
|
|
4613
|
-
for (const r of records) await idbReq(store.delete(r.id));
|
|
4614
|
-
},
|
|
4615
|
-
async deletePlaythroughById(id) {
|
|
4616
|
-
await idbReq((await tx("playthroughs", "readwrite")).delete(id));
|
|
4617
|
-
},
|
|
4618
|
-
async getMeta(key) {
|
|
4619
|
-
const row = await idbReq((await tx("meta", "readonly")).get(key));
|
|
4620
|
-
return row ? row.value : void 0;
|
|
4621
|
-
},
|
|
4622
|
-
async setMeta(key, value) {
|
|
4623
|
-
await idbReq((await tx("meta", "readwrite")).put({
|
|
4624
|
-
key,
|
|
4625
|
-
value
|
|
4626
|
-
}));
|
|
4627
|
-
},
|
|
4628
|
-
async deleteMeta(key) {
|
|
4629
|
-
await idbReq((await tx("meta", "readwrite")).delete(key));
|
|
4630
|
-
},
|
|
4631
|
-
async deleteMetaByPrefix(prefix) {
|
|
4632
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
4633
|
-
for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
4634
|
-
},
|
|
4635
|
-
async deleteMetaByIfid(ifid) {
|
|
4636
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
4637
|
-
for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
4638
|
-
},
|
|
4639
|
-
async getAllMetaKeys() {
|
|
4640
|
-
return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
|
|
4641
|
-
},
|
|
4642
|
-
async destroy() {
|
|
4643
|
-
(await openDB()).close();
|
|
4644
|
-
dbPromise = null;
|
|
4645
|
-
await new Promise((resolve, reject) => {
|
|
4646
|
-
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
4647
|
-
req.onsuccess = () => resolve();
|
|
4648
|
-
req.onerror = () => reject(req.error);
|
|
4649
|
-
});
|
|
4650
|
-
}
|
|
4651
|
-
};
|
|
4785
|
+
}
|
|
4786
|
+
function idbReq(req) {
|
|
4787
|
+
return new Promise((resolve, reject) => {
|
|
4788
|
+
req.onsuccess = () => resolve(req.result);
|
|
4789
|
+
req.onerror = () => reject(req.error);
|
|
4790
|
+
});
|
|
4791
|
+
}
|
|
4792
|
+
/**
|
|
4793
|
+
* An object store. Records hold their own key (keyPath); meta values are
|
|
4794
|
+
* stored as `{ key, value }` rows.
|
|
4795
|
+
*/
|
|
4796
|
+
function table(name) {
|
|
4797
|
+
const rows = name === "meta";
|
|
4798
|
+
/** Run one request in a transaction of its own. */
|
|
4799
|
+
const request = async (mode, make) => {
|
|
4800
|
+
return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
|
|
4801
|
+
};
|
|
4802
|
+
return {
|
|
4803
|
+
async get(key) {
|
|
4804
|
+
const result = await request("readonly", (s) => s.get(key));
|
|
4805
|
+
return rows ? result?.value : result;
|
|
4806
|
+
},
|
|
4807
|
+
async put(key, value) {
|
|
4808
|
+
await request("readwrite", (s) => s.put(rows ? {
|
|
4809
|
+
key,
|
|
4810
|
+
value
|
|
4811
|
+
} : value));
|
|
4812
|
+
},
|
|
4813
|
+
async delete(key) {
|
|
4814
|
+
await request("readwrite", (s) => s.delete(key));
|
|
4815
|
+
},
|
|
4816
|
+
async find(field, value) {
|
|
4817
|
+
return await request("readonly", (s) => s.index(field).getAll(value)) ?? [];
|
|
4818
|
+
},
|
|
4819
|
+
keys: async () => await request("readonly", (s) => s.getAllKeys())
|
|
4820
|
+
};
|
|
4821
|
+
}
|
|
4822
|
+
return createBackend("indexeddb", makeTables(table), async () => {
|
|
4823
|
+
(await openDB()).close();
|
|
4824
|
+
dbPromise = null;
|
|
4825
|
+
await new Promise((resolve, reject) => {
|
|
4826
|
+
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
4827
|
+
req.onsuccess = () => resolve();
|
|
4828
|
+
req.onerror = () => reject(req.error);
|
|
4829
|
+
});
|
|
4830
|
+
});
|
|
4652
4831
|
}
|
|
4653
4832
|
var _backend = null;
|
|
4654
4833
|
async function detectBackend() {
|
|
@@ -4797,15 +4976,17 @@ function inOrder(op) {
|
|
|
4797
4976
|
operationQueue = run.catch(() => {});
|
|
4798
4977
|
return run;
|
|
4799
4978
|
}
|
|
4979
|
+
/** `op` (a `...Now` helper) as an operation that queues itself. */
|
|
4980
|
+
function queued(op) {
|
|
4981
|
+
return (...args) => inOrder(() => op(...args));
|
|
4982
|
+
}
|
|
4800
4983
|
/**
|
|
4801
4984
|
* Store a new playthrough and make it the story's current one. Pass `id` when
|
|
4802
4985
|
* the caller has already switched to the playthrough (restart() does, so
|
|
4803
4986
|
* saves issued before this resolves are tagged with it).
|
|
4804
4987
|
*/
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
}
|
|
4808
|
-
async function startNewPlaythroughNow(ifid, id) {
|
|
4988
|
+
var startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
4989
|
+
async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
|
|
4809
4990
|
const backend = await getBackend();
|
|
4810
4991
|
const num = await nextPlaythroughNumber(ifid);
|
|
4811
4992
|
const record = {
|
|
@@ -4850,19 +5031,26 @@ function currentPlaythroughKey(ifid) {
|
|
|
4850
5031
|
* recorded again as "Imported", as importing the save would: it was not
|
|
4851
5032
|
* started here, so it takes no number.
|
|
4852
5033
|
*/
|
|
4853
|
-
|
|
4854
|
-
return inOrder(() => adoptPlaythroughNow(ifid, id));
|
|
4855
|
-
}
|
|
5034
|
+
var adoptPlaythrough = queued(adoptPlaythroughNow);
|
|
4856
5035
|
async function adoptPlaythroughNow(ifid, id) {
|
|
5036
|
+
const backend = await getBackend();
|
|
5037
|
+
await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
|
|
5038
|
+
const key = currentPlaythroughKey(ifid);
|
|
5039
|
+
if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
|
|
5040
|
+
}
|
|
5041
|
+
/**
|
|
5042
|
+
* Record the playthrough `id` of the story as "Imported", created at
|
|
5043
|
+
* `createdAt`, unless it has a record: a playthrough not started here takes
|
|
5044
|
+
* no number.
|
|
5045
|
+
*/
|
|
5046
|
+
async function ensurePlaythrough(ifid, id, createdAt) {
|
|
4857
5047
|
const backend = await getBackend();
|
|
4858
5048
|
if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === id)) await backend.putPlaythrough({
|
|
4859
5049
|
id,
|
|
4860
5050
|
ifid,
|
|
4861
|
-
createdAt
|
|
5051
|
+
createdAt,
|
|
4862
5052
|
label: "Imported"
|
|
4863
5053
|
});
|
|
4864
|
-
const key = currentPlaythroughKey(ifid);
|
|
4865
|
-
if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
|
|
4866
5054
|
}
|
|
4867
5055
|
/**
|
|
4868
5056
|
* Set up the save system for a story that boots: the story's current
|
|
@@ -4872,7 +5060,7 @@ async function adoptPlaythroughNow(ifid, id) {
|
|
|
4872
5060
|
function establishPlaythrough(ifid) {
|
|
4873
5061
|
return inOrder(async () => {
|
|
4874
5062
|
return {
|
|
4875
|
-
id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid
|
|
5063
|
+
id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
|
|
4876
5064
|
knownSaves: await populateKnownSavesNow(ifid)
|
|
4877
5065
|
};
|
|
4878
5066
|
});
|
|
@@ -4907,12 +5095,35 @@ function saveWithHooks(slot, custom, beginCapture, write) {
|
|
|
4907
5095
|
return Promise.reject(err);
|
|
4908
5096
|
}
|
|
4909
5097
|
}
|
|
4910
|
-
|
|
4911
|
-
|
|
5098
|
+
/**
|
|
5099
|
+
* `payload` with `convert` applied to its live variables and to those of
|
|
5100
|
+
* every history moment.
|
|
5101
|
+
*/
|
|
5102
|
+
function mapVariables(payload, convert) {
|
|
5103
|
+
return {
|
|
5104
|
+
...payload,
|
|
5105
|
+
variables: convert(payload.variables),
|
|
5106
|
+
history: payload.history.map((m) => ({
|
|
5107
|
+
...m,
|
|
5108
|
+
variables: convert(m.variables)
|
|
5109
|
+
}))
|
|
5110
|
+
};
|
|
5111
|
+
}
|
|
5112
|
+
/** The record to store for a save: `payload` serialized, and its size. */
|
|
5113
|
+
function storedRecord(meta, payload) {
|
|
5114
|
+
const serialized = mapVariables(deepClone(payload), serialize);
|
|
5115
|
+
return {
|
|
5116
|
+
meta: {
|
|
5117
|
+
...meta,
|
|
5118
|
+
estimatedBytes: estimatePayloadBytes(serialized)
|
|
5119
|
+
},
|
|
5120
|
+
payload: serialized
|
|
5121
|
+
};
|
|
4912
5122
|
}
|
|
4913
|
-
|
|
5123
|
+
var createSave = queued(createSaveNow);
|
|
5124
|
+
async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
|
|
4914
5125
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4915
|
-
const
|
|
5126
|
+
const record = storedRecord({
|
|
4916
5127
|
id: crypto.randomUUID(),
|
|
4917
5128
|
ifid,
|
|
4918
5129
|
playthroughId,
|
|
@@ -4921,18 +5132,7 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
|
|
|
4921
5132
|
title: generateTitle(payload),
|
|
4922
5133
|
passage: payload.passage,
|
|
4923
5134
|
custom
|
|
4924
|
-
};
|
|
4925
|
-
const serializedPayload = deepClone(payload);
|
|
4926
|
-
serializedPayload.variables = serialize(serializedPayload.variables);
|
|
4927
|
-
serializedPayload.history = serializedPayload.history.map((m) => ({
|
|
4928
|
-
...m,
|
|
4929
|
-
variables: serialize(m.variables)
|
|
4930
|
-
}));
|
|
4931
|
-
const record = {
|
|
4932
|
-
meta,
|
|
4933
|
-
payload: serializedPayload
|
|
4934
|
-
};
|
|
4935
|
-
record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
5135
|
+
}, payload);
|
|
4936
5136
|
await (await getBackend()).putSave(record);
|
|
4937
5137
|
return record;
|
|
4938
5138
|
}
|
|
@@ -4951,34 +5151,22 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
|
|
|
4951
5151
|
* save itself rather than describing its content. `custom` is merged, as
|
|
4952
5152
|
* before, so slot keys and metadata not passed again are kept.
|
|
4953
5153
|
*/
|
|
4954
|
-
|
|
4955
|
-
return inOrder(() => overwriteSaveNow(saveId, payload, custom, playthroughId));
|
|
4956
|
-
}
|
|
5154
|
+
var overwriteSave = queued(overwriteSaveNow);
|
|
4957
5155
|
async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
|
|
4958
5156
|
const backend = await getBackend();
|
|
4959
5157
|
const existing = await backend.getSave(saveId);
|
|
4960
5158
|
if (!existing) return void 0;
|
|
4961
|
-
const
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
...
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
|
|
4973
|
-
passage: payload.passage,
|
|
4974
|
-
...custom != null ? { custom: {
|
|
4975
|
-
...existing.meta.custom,
|
|
4976
|
-
...custom
|
|
4977
|
-
} } : {}
|
|
4978
|
-
},
|
|
4979
|
-
payload: serializedPayload
|
|
4980
|
-
};
|
|
4981
|
-
updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
|
|
5159
|
+
const updated = storedRecord({
|
|
5160
|
+
...existing.meta,
|
|
5161
|
+
...playthroughId ? { playthroughId } : {},
|
|
5162
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5163
|
+
title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
|
|
5164
|
+
passage: payload.passage,
|
|
5165
|
+
...custom != null ? { custom: {
|
|
5166
|
+
...existing.meta.custom,
|
|
5167
|
+
...custom
|
|
5168
|
+
} } : {}
|
|
5169
|
+
}, payload);
|
|
4982
5170
|
await backend.putSave(updated);
|
|
4983
5171
|
return updated;
|
|
4984
5172
|
}
|
|
@@ -4988,15 +5176,11 @@ async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
|
|
|
4988
5176
|
* untouched. Payloads handed to the store's `loadFromPayload()` must be live.
|
|
4989
5177
|
*/
|
|
4990
5178
|
function deserializePayload(payload) {
|
|
4991
|
-
return
|
|
4992
|
-
...payload,
|
|
4993
|
-
variables: deserialize(payload.variables),
|
|
4994
|
-
history: payload.history.map((m) => ({
|
|
4995
|
-
...m,
|
|
4996
|
-
variables: deserialize(m.variables)
|
|
4997
|
-
}))
|
|
4998
|
-
};
|
|
5179
|
+
return mapVariables(payload, deserialize);
|
|
4999
5180
|
}
|
|
5181
|
+
/** The live payload of a stored save, if there is one. */
|
|
5182
|
+
var livePayload = (record) => record && deserializePayload(record.payload);
|
|
5183
|
+
queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
|
|
5000
5184
|
/**
|
|
5001
5185
|
* Delete a save record. If the default slot or a named slot holds it, that
|
|
5002
5186
|
* slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
|
|
@@ -5007,14 +5191,7 @@ function deleteSaveById(saveId) {
|
|
|
5007
5191
|
const record = await backend.getSave(saveId);
|
|
5008
5192
|
await backend.deleteSave(saveId);
|
|
5009
5193
|
if (!record) return;
|
|
5010
|
-
|
|
5011
|
-
const slots = [void 0, ...await getIndexedSlots(ifid)];
|
|
5012
|
-
for (const slot of slots) {
|
|
5013
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5014
|
-
if (await backend.getMeta(metaKey) !== saveId) continue;
|
|
5015
|
-
await backend.deleteMeta(metaKey);
|
|
5016
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5017
|
-
}
|
|
5194
|
+
await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
|
|
5018
5195
|
});
|
|
5019
5196
|
}
|
|
5020
5197
|
function renameSave(saveId, newTitle) {
|
|
@@ -5034,10 +5211,7 @@ function renameSave(saveId, newTitle) {
|
|
|
5034
5211
|
await backend.putSave(updated);
|
|
5035
5212
|
});
|
|
5036
5213
|
}
|
|
5037
|
-
|
|
5038
|
-
return inOrder(() => getSavesGroupedNow(ifid));
|
|
5039
|
-
}
|
|
5040
|
-
async function getSavesGroupedNow(ifid) {
|
|
5214
|
+
var getSavesGrouped = queued(async function getSavesGroupedNow(ifid) {
|
|
5041
5215
|
const backend = await getBackend();
|
|
5042
5216
|
const [allSaves, allPlaythroughs] = await Promise.all([backend.getSavesByIfid(ifid), backend.getPlaythroughsByIfid(ifid)]);
|
|
5043
5217
|
const ptMap = /* @__PURE__ */ new Map();
|
|
@@ -5069,7 +5243,7 @@ async function getSavesGroupedNow(ifid) {
|
|
|
5069
5243
|
saves
|
|
5070
5244
|
});
|
|
5071
5245
|
return result;
|
|
5072
|
-
}
|
|
5246
|
+
});
|
|
5073
5247
|
var AUTOSAVE_KEY_PREFIX = "autosave.";
|
|
5074
5248
|
var SLOT_KEY_PREFIX = "slot.";
|
|
5075
5249
|
var SLOT_INDEX_KEY_PREFIX = "slotIndex.";
|
|
@@ -5083,29 +5257,49 @@ function isNamedSlot(slot) {
|
|
|
5083
5257
|
function slotMetaKey(ifid, slot) {
|
|
5084
5258
|
return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
5085
5259
|
}
|
|
5260
|
+
var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
|
|
5086
5261
|
/** Read-modify-write the per-story slot index. */
|
|
5087
5262
|
async function updateSlotIndex(ifid, update) {
|
|
5088
5263
|
const backend = await getBackend();
|
|
5089
|
-
const
|
|
5090
|
-
const existing = await backend.getMeta(indexKey) ?? [];
|
|
5264
|
+
const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
|
|
5091
5265
|
const updated = update(existing);
|
|
5092
|
-
if (updated !== existing) await backend.setMeta(
|
|
5093
|
-
}
|
|
5094
|
-
/**
|
|
5095
|
-
function
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5266
|
+
if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
|
|
5267
|
+
}
|
|
5268
|
+
/** Every slot: the default one (undefined), then those in the slot index. */
|
|
5269
|
+
async function allSlots(ifid) {
|
|
5270
|
+
return [void 0, ...(await (await getBackend()).getMeta(slotIndexKey(ifid)) ?? []).filter(isNamedSlot)];
|
|
5271
|
+
}
|
|
5272
|
+
/** The ID of the save a slot holds, if any. */
|
|
5273
|
+
async function slotSaveId(ifid, slot) {
|
|
5274
|
+
return (await getBackend()).getMeta(slotMetaKey(ifid, slot));
|
|
5275
|
+
}
|
|
5276
|
+
/** The record of the save a slot holds, if there is one. */
|
|
5277
|
+
async function slotRecord(ifid, slot) {
|
|
5278
|
+
const id = await slotSaveId(ifid, slot);
|
|
5279
|
+
return id ? (await getBackend()).getSave(id) : void 0;
|
|
5280
|
+
}
|
|
5281
|
+
/** Make a slot hold the save `saveId`: its pointer, and the slot index. */
|
|
5282
|
+
async function fillSlot(ifid, slot, saveId) {
|
|
5283
|
+
await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
|
|
5284
|
+
if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
|
|
5285
|
+
}
|
|
5286
|
+
/** Clear a slot: its pointer, and its entry in the slot index. */
|
|
5287
|
+
async function emptySlot(ifid, slot) {
|
|
5288
|
+
await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
|
|
5289
|
+
if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
|
|
5290
|
+
}
|
|
5291
|
+
/** Clear every slot holding a save whose ID passes `test`. */
|
|
5292
|
+
async function emptySlotsHolding(ifid, test) {
|
|
5293
|
+
for (const slot of await allSlots(ifid)) {
|
|
5294
|
+
const id = await slotSaveId(ifid, slot);
|
|
5295
|
+
if (id && test(id)) await emptySlot(ifid, slot);
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
/** The `custom` keys that say which slot a save lives in. */
|
|
5299
|
+
var slotCustom = (slot) => ({
|
|
5300
|
+
isAutosave: !isNamedSlot(slot),
|
|
5301
|
+
...isNamedSlot(slot) ? { slot } : {}
|
|
5302
|
+
});
|
|
5109
5303
|
function toSaveInfo(record, slot) {
|
|
5110
5304
|
return {
|
|
5111
5305
|
slot: isNamedSlot(slot) ? slot : "",
|
|
@@ -5123,32 +5317,26 @@ function toSaveInfo(record, slot) {
|
|
|
5123
5317
|
* place in the order of operations when it is called.
|
|
5124
5318
|
*/
|
|
5125
5319
|
function quickSave(ifid, playthroughId, payload, slot, custom) {
|
|
5126
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5127
5320
|
return inOrder(async () => {
|
|
5128
5321
|
const ptId = await playthroughId;
|
|
5129
|
-
const
|
|
5130
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5322
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
5131
5323
|
if (existingId) {
|
|
5132
5324
|
const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
|
|
5133
5325
|
if (updated) return updated;
|
|
5134
5326
|
}
|
|
5135
5327
|
const record = await createSaveNow(ifid, ptId, payload, {
|
|
5136
|
-
|
|
5137
|
-
...isNamedSlot(slot) ? { slot } : {},
|
|
5328
|
+
...slotCustom(slot),
|
|
5138
5329
|
...custom
|
|
5139
5330
|
});
|
|
5140
|
-
await
|
|
5141
|
-
await addToSlotIndex(ifid, slot);
|
|
5331
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
5142
5332
|
return record;
|
|
5143
5333
|
});
|
|
5144
5334
|
}
|
|
5335
|
+
queued(hasQuickSaveNow);
|
|
5145
5336
|
async function hasQuickSaveNow(ifid, slot) {
|
|
5146
|
-
|
|
5147
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5148
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5149
|
-
if (!existingId) return false;
|
|
5150
|
-
return await backend.getSave(existingId) !== void 0;
|
|
5337
|
+
return await slotRecord(ifid, slot) !== void 0;
|
|
5151
5338
|
}
|
|
5339
|
+
queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
|
|
5152
5340
|
/**
|
|
5153
5341
|
* Read the save held in a slot (the default slot when `slot` is omitted) for
|
|
5154
5342
|
* the running game to load, and make its playthrough the story's current one
|
|
@@ -5157,12 +5345,8 @@ async function hasQuickSaveNow(ifid, slot) {
|
|
|
5157
5345
|
* and its playthrough, or undefined if the slot is empty.
|
|
5158
5346
|
*/
|
|
5159
5347
|
function loadSlotSave(ifid, slot) {
|
|
5160
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5161
5348
|
return inOrder(async () => {
|
|
5162
|
-
const
|
|
5163
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5164
|
-
if (!existingId) return void 0;
|
|
5165
|
-
const record = await backend.getSave(existingId);
|
|
5349
|
+
const record = await slotRecord(ifid, slot);
|
|
5166
5350
|
if (!record) return void 0;
|
|
5167
5351
|
const payload = deserializePayload(record.payload);
|
|
5168
5352
|
const { playthroughId } = record.meta;
|
|
@@ -5177,60 +5361,41 @@ function loadSlotSave(ifid, slot) {
|
|
|
5177
5361
|
* Check storage for all known saves and return a map of slot keys to true.
|
|
5178
5362
|
* The default (autosave) slot uses empty string as key.
|
|
5179
5363
|
*/
|
|
5180
|
-
|
|
5181
|
-
return inOrder(() => populateKnownSavesNow(ifid));
|
|
5182
|
-
}
|
|
5364
|
+
var populateKnownSaves = queued(populateKnownSavesNow);
|
|
5183
5365
|
async function populateKnownSavesNow(ifid) {
|
|
5184
5366
|
const result = Object.create(null);
|
|
5185
|
-
if (await hasQuickSaveNow(ifid)) result[""] = true;
|
|
5186
|
-
for (const slot of await getIndexedSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot] = true;
|
|
5367
|
+
for (const slot of await allSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ""] = true;
|
|
5187
5368
|
return result;
|
|
5188
5369
|
}
|
|
5189
5370
|
/**
|
|
5190
5371
|
* Get metadata for a specific save slot.
|
|
5191
5372
|
* Returns null if no save exists for that slot.
|
|
5192
5373
|
*/
|
|
5193
|
-
|
|
5194
|
-
return inOrder(() => getSlotSaveInfoNow(ifid, slot));
|
|
5195
|
-
}
|
|
5374
|
+
var getSlotSaveInfo = queued(getSlotSaveInfoNow);
|
|
5196
5375
|
async function getSlotSaveInfoNow(ifid, slot) {
|
|
5197
|
-
const
|
|
5198
|
-
|
|
5199
|
-
const existingId = await backend.getMeta(metaKey);
|
|
5200
|
-
if (!existingId) return null;
|
|
5201
|
-
const record = await backend.getSave(existingId);
|
|
5202
|
-
if (!record) return null;
|
|
5203
|
-
return toSaveInfo(record, slot);
|
|
5376
|
+
const record = await slotRecord(ifid, slot);
|
|
5377
|
+
return record ? toSaveInfo(record, slot) : null;
|
|
5204
5378
|
}
|
|
5205
5379
|
/**
|
|
5206
5380
|
* List metadata for all known save slots (default + named).
|
|
5207
5381
|
*/
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
const
|
|
5212
|
-
if (
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
}
|
|
5217
|
-
return result;
|
|
5218
|
-
});
|
|
5219
|
-
}
|
|
5382
|
+
var listSlotSaves = queued(async (ifid) => {
|
|
5383
|
+
const result = [];
|
|
5384
|
+
for (const slot of await allSlots(ifid)) {
|
|
5385
|
+
const info = await getSlotSaveInfoNow(ifid, slot);
|
|
5386
|
+
if (info) result.push(info);
|
|
5387
|
+
}
|
|
5388
|
+
return result;
|
|
5389
|
+
});
|
|
5220
5390
|
/**
|
|
5221
5391
|
* Delete a save by slot name. Removes from slot index if named.
|
|
5222
5392
|
*/
|
|
5223
|
-
|
|
5224
|
-
const
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
await backend.deleteSave(existingId);
|
|
5230
|
-
await backend.deleteMeta(metaKey);
|
|
5231
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5232
|
-
});
|
|
5233
|
-
}
|
|
5393
|
+
var deleteSlotSave = queued(async (ifid, slot) => {
|
|
5394
|
+
const existingId = await slotSaveId(ifid, slot);
|
|
5395
|
+
if (!existingId) return;
|
|
5396
|
+
await (await getBackend()).deleteSave(existingId);
|
|
5397
|
+
await emptySlot(ifid, slot);
|
|
5398
|
+
});
|
|
5234
5399
|
var SESSION_KEY_PREFIX = "spindle.session.";
|
|
5235
5400
|
/**
|
|
5236
5401
|
* Write a pre-serialized session payload to sessionStorage.
|
|
@@ -5257,11 +5422,8 @@ function clearSession(ifid) {
|
|
|
5257
5422
|
sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
5258
5423
|
} catch {}
|
|
5259
5424
|
}
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
}
|
|
5263
|
-
async function exportSaveNow(saveId) {
|
|
5264
|
-
const record = await (await getBackend()).getSave(saveId);
|
|
5425
|
+
/** The export of a stored save, if there is one. */
|
|
5426
|
+
function toExport(record) {
|
|
5265
5427
|
if (!record) return void 0;
|
|
5266
5428
|
return {
|
|
5267
5429
|
version: 1,
|
|
@@ -5270,6 +5432,7 @@ async function exportSaveNow(saveId) {
|
|
|
5270
5432
|
save: record
|
|
5271
5433
|
};
|
|
5272
5434
|
}
|
|
5435
|
+
var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
|
|
5273
5436
|
/**
|
|
5274
5437
|
* Validate an export against the running story and build the record to store:
|
|
5275
5438
|
* a fresh save ID, and the playthrough record created if it doesn't exist yet.
|
|
@@ -5279,20 +5442,11 @@ async function prepareImport(data, ifid) {
|
|
|
5279
5442
|
if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
|
|
5280
5443
|
if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
|
|
5281
5444
|
if (!isSaveExport(data)) throw new Error("Invalid save file format");
|
|
5282
|
-
const backend = await getBackend();
|
|
5283
5445
|
const record = deepClone(data.save);
|
|
5284
5446
|
record.meta.id = crypto.randomUUID();
|
|
5285
5447
|
record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5286
5448
|
record.meta.ifid = ifid;
|
|
5287
|
-
|
|
5288
|
-
const pt = {
|
|
5289
|
-
id: record.meta.playthroughId,
|
|
5290
|
-
ifid,
|
|
5291
|
-
createdAt: record.meta.createdAt,
|
|
5292
|
-
label: "Imported"
|
|
5293
|
-
};
|
|
5294
|
-
await backend.putPlaythrough(pt);
|
|
5295
|
-
}
|
|
5449
|
+
await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
|
|
5296
5450
|
return record;
|
|
5297
5451
|
}
|
|
5298
5452
|
function importSave(data, ifid) {
|
|
@@ -5306,13 +5460,7 @@ function importSave(data, ifid) {
|
|
|
5306
5460
|
* Export the save held in a slot (default autosave slot when `slot` is omitted).
|
|
5307
5461
|
* Returns undefined if the slot is empty.
|
|
5308
5462
|
*/
|
|
5309
|
-
|
|
5310
|
-
return inOrder(async () => {
|
|
5311
|
-
const saveId = await (await getBackend()).getMeta(slotMetaKey(ifid, slot));
|
|
5312
|
-
if (!saveId) return void 0;
|
|
5313
|
-
return exportSaveNow(saveId);
|
|
5314
|
-
});
|
|
5315
|
-
}
|
|
5463
|
+
var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
|
|
5316
5464
|
/**
|
|
5317
5465
|
* Import an exported save into a slot (default autosave slot when `slot` is
|
|
5318
5466
|
* omitted), replacing any save already in that slot. Applies the same
|
|
@@ -5320,35 +5468,29 @@ function exportSlotSave(ifid, slot) {
|
|
|
5320
5468
|
* and slot index consistent.
|
|
5321
5469
|
*/
|
|
5322
5470
|
function importSlotSave(data, ifid, slot) {
|
|
5323
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5324
5471
|
return inOrder(async () => {
|
|
5325
5472
|
const record = await prepareImport(data, ifid);
|
|
5326
5473
|
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
5327
5474
|
record.meta.custom = {
|
|
5328
5475
|
...custom,
|
|
5329
|
-
|
|
5330
|
-
...isNamedSlot(slot) ? { slot } : {}
|
|
5476
|
+
...slotCustom(slot)
|
|
5331
5477
|
};
|
|
5332
5478
|
const backend = await getBackend();
|
|
5333
|
-
const previousId = await
|
|
5479
|
+
const previousId = await slotSaveId(ifid, slot);
|
|
5334
5480
|
await backend.putSave(record);
|
|
5335
|
-
await
|
|
5336
|
-
await addToSlotIndex(ifid, slot);
|
|
5481
|
+
await fillSlot(ifid, slot, record.meta.id);
|
|
5337
5482
|
if (previousId) await backend.deleteSave(previousId);
|
|
5338
5483
|
return toSaveInfo(record, slot);
|
|
5339
5484
|
});
|
|
5340
5485
|
}
|
|
5341
|
-
|
|
5342
|
-
return inOrder(() => getStorageInfoNow(ifid));
|
|
5343
|
-
}
|
|
5344
|
-
async function getStorageInfoNow(ifid) {
|
|
5486
|
+
var getStorageInfo = queued(async function getStorageInfoNow(ifid) {
|
|
5345
5487
|
const backend = await getBackend();
|
|
5346
5488
|
const saves = await backend.getSavesByIfid(ifid);
|
|
5347
5489
|
const playthroughs = await backend.getPlaythroughsByIfid(ifid);
|
|
5348
5490
|
let totalBytes = 0;
|
|
5349
5491
|
for (const save of saves) if (save.meta.estimatedBytes != null) totalBytes += save.meta.estimatedBytes;
|
|
5350
5492
|
else {
|
|
5351
|
-
const bytes =
|
|
5493
|
+
const bytes = estimatePayloadBytes(save.payload);
|
|
5352
5494
|
save.meta.estimatedBytes = bytes;
|
|
5353
5495
|
await backend.putSave(save);
|
|
5354
5496
|
totalBytes += bytes;
|
|
@@ -5359,7 +5501,7 @@ async function getStorageInfoNow(ifid) {
|
|
|
5359
5501
|
totalBytes,
|
|
5360
5502
|
backend: backend.type
|
|
5361
5503
|
};
|
|
5362
|
-
}
|
|
5504
|
+
});
|
|
5363
5505
|
function clearGameData(ifid) {
|
|
5364
5506
|
return inOrder(async () => {
|
|
5365
5507
|
const backend = await getBackend();
|
|
@@ -5369,9 +5511,7 @@ function clearGameData(ifid) {
|
|
|
5369
5511
|
clearSession(ifid);
|
|
5370
5512
|
});
|
|
5371
5513
|
}
|
|
5372
|
-
|
|
5373
|
-
return inOrder(clearAllDataNow);
|
|
5374
|
-
}
|
|
5514
|
+
var clearAllData = queued(clearAllDataNow);
|
|
5375
5515
|
async function clearAllDataNow() {
|
|
5376
5516
|
await (await getBackend()).destroy();
|
|
5377
5517
|
resetBackend();
|
|
@@ -5399,14 +5539,7 @@ function deletePlaythroughData(ifid, playthroughId, replacement) {
|
|
|
5399
5539
|
const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
|
|
5400
5540
|
await backend.deletePlaythroughById(playthroughId);
|
|
5401
5541
|
const deletedSet = new Set(deletedSaveIds);
|
|
5402
|
-
|
|
5403
|
-
const metaKey = slotMetaKey(ifid, slot);
|
|
5404
|
-
const value = await backend.getMeta(metaKey);
|
|
5405
|
-
if (value && deletedSet.has(value)) {
|
|
5406
|
-
await backend.deleteMeta(metaKey);
|
|
5407
|
-
await removeFromSlotIndex(ifid, slot);
|
|
5408
|
-
}
|
|
5409
|
-
}
|
|
5542
|
+
await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
|
|
5410
5543
|
if (replacement && await replacement.current === playthroughId) {
|
|
5411
5544
|
await startNewPlaythroughNow(ifid, replacement.id);
|
|
5412
5545
|
return true;
|
|
@@ -5467,120 +5600,6 @@ function initializeStory(session) {
|
|
|
5467
5600
|
if (!session) recordStoryInitState();
|
|
5468
5601
|
}
|
|
5469
5602
|
//#endregion
|
|
5470
|
-
//#region src/utils/object-path.ts
|
|
5471
|
-
/**
|
|
5472
|
-
* Traverse dot-path segments on an object and return the nested value.
|
|
5473
|
-
* Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
|
|
5474
|
-
* are not story state: unless an object holds one as its own property, it
|
|
5475
|
-
* reads as missing, as setByPath() treats it. Other inherited properties
|
|
5476
|
-
* (class getters, `size` of a Map) are read.
|
|
5477
|
-
*/
|
|
5478
|
-
function getByPath(obj, segments) {
|
|
5479
|
-
let current = obj;
|
|
5480
|
-
for (const seg of segments) {
|
|
5481
|
-
if (current == null || typeof current !== "object") return void 0;
|
|
5482
|
-
if (seg in Object.prototype && !hasOwn$2(current, seg)) return void 0;
|
|
5483
|
-
current = current[seg];
|
|
5484
|
-
}
|
|
5485
|
-
return current;
|
|
5486
|
-
}
|
|
5487
|
-
/** Array indices ("0", "1", …) and "length": the keys an array holds. */
|
|
5488
|
-
var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
|
|
5489
|
-
/**
|
|
5490
|
-
* Built-ins whose content is not their properties: a property written into
|
|
5491
|
-
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
5492
|
-
*/
|
|
5493
|
-
var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
|
|
5494
|
-
/**
|
|
5495
|
-
* Shallow copy that keeps the prototype, so a registered class instance
|
|
5496
|
-
* stays an instance of its class (with the same own keys deepClone copies).
|
|
5497
|
-
*/
|
|
5498
|
-
function shallowCopy(value) {
|
|
5499
|
-
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
5500
|
-
for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
|
|
5501
|
-
value: value[key],
|
|
5502
|
-
enumerable: true,
|
|
5503
|
-
writable: true,
|
|
5504
|
-
configurable: true
|
|
5505
|
-
});
|
|
5506
|
-
return copy;
|
|
5507
|
-
}
|
|
5508
|
-
/**
|
|
5509
|
-
* Set a value at dot-path `segments` below `root`.
|
|
5510
|
-
*
|
|
5511
|
-
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
5512
|
-
* class instances and everything below them) are copied on the way down and
|
|
5513
|
-
* the copy is assigned to its parent, so the write produces new references
|
|
5514
|
-
* up to the root. Writing into such an object directly would mutate the
|
|
5515
|
-
* instance shared with the previous state and with history, and subscribers
|
|
5516
|
-
* would see no change. Outside a draft (a private working copy) the path is
|
|
5517
|
-
* written in place.
|
|
5518
|
-
*
|
|
5519
|
-
* The path goes through own properties of plain objects, class instances
|
|
5520
|
-
* and arrays (by index); anything else throws a TypeError rather than
|
|
5521
|
-
* writing where no clone or save would see it: an inherited property such
|
|
5522
|
-
* as a method counts as missing, and Map, Set, Date and RegExp values and
|
|
5523
|
-
* other keys of arrays are refused, as is a "__proto__" segment.
|
|
5524
|
-
*/
|
|
5525
|
-
function setByPath(root, segments, value, options = {}) {
|
|
5526
|
-
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
5527
|
-
parent[segments[segments.length - 1]] = value;
|
|
5528
|
-
}
|
|
5529
|
-
/**
|
|
5530
|
-
* Delete the property at dot-path `segments` below `root`, copying
|
|
5531
|
-
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
5532
|
-
* intermediate is missing or not an object, or the property is absent (or
|
|
5533
|
-
* only inherited).
|
|
5534
|
-
*/
|
|
5535
|
-
function deleteByPath(root, segments) {
|
|
5536
|
-
const last = segments[segments.length - 1];
|
|
5537
|
-
checkSegments(segments);
|
|
5538
|
-
let holder = root;
|
|
5539
|
-
for (const seg of segments.slice(0, -1)) {
|
|
5540
|
-
if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, seg)) return;
|
|
5541
|
-
holder = holder[seg];
|
|
5542
|
-
}
|
|
5543
|
-
if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, last)) return;
|
|
5544
|
-
const parent = walkToParent(root, segments, false);
|
|
5545
|
-
delete parent[last];
|
|
5546
|
-
}
|
|
5547
|
-
/** Refuse a segment that would reach a prototype instead of story state. */
|
|
5548
|
-
function checkSegments(segments) {
|
|
5549
|
-
if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
|
|
5550
|
-
}
|
|
5551
|
-
/** Throw unless `holder` can take `key` as story state (see setByPath). */
|
|
5552
|
-
function checkHolder(holder, key, segments, depth) {
|
|
5553
|
-
const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
|
|
5554
|
-
if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
|
|
5555
|
-
}
|
|
5556
|
-
/**
|
|
5557
|
-
* Walk to the object holding the last of `segments`, copying undrafted
|
|
5558
|
-
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
5559
|
-
* non-object intermediate is replaced by a plain object when
|
|
5560
|
-
* `createMissing` is set, and throws a TypeError otherwise.
|
|
5561
|
-
*/
|
|
5562
|
-
function walkToParent(root, segments, createMissing) {
|
|
5563
|
-
checkSegments(segments);
|
|
5564
|
-
const copyOnWrite = isDraft(root);
|
|
5565
|
-
let current = root;
|
|
5566
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
5567
|
-
const seg = segments[i];
|
|
5568
|
-
checkHolder(current, seg, segments, i);
|
|
5569
|
-
let next = hasOwn$2(current, seg) ? current[seg] : void 0;
|
|
5570
|
-
if (next == null || typeof next !== "object") {
|
|
5571
|
-
if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
|
|
5572
|
-
next = {};
|
|
5573
|
-
current[seg] = next;
|
|
5574
|
-
} else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
|
|
5575
|
-
next = shallowCopy(next);
|
|
5576
|
-
current[seg] = next;
|
|
5577
|
-
}
|
|
5578
|
-
current = next;
|
|
5579
|
-
}
|
|
5580
|
-
checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
|
|
5581
|
-
return current;
|
|
5582
|
-
}
|
|
5583
|
-
//#endregion
|
|
5584
5603
|
//#region src/execute-mutation.ts
|
|
5585
5604
|
var NAMESPACES = [
|
|
5586
5605
|
"variables",
|
|
@@ -5622,54 +5641,6 @@ function frozenCopy(value) {
|
|
|
5622
5641
|
return freeze(cloneValue(value), true);
|
|
5623
5642
|
}
|
|
5624
5643
|
/**
|
|
5625
|
-
* Objects merged property by property: plain objects and (registered)
|
|
5626
|
-
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
5627
|
-
* replaced as a whole, since their elements have no stable identity to
|
|
5628
|
-
* merge by (a shift moves every index).
|
|
5629
|
-
*/
|
|
5630
|
-
function isMergeable(value) {
|
|
5631
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
5632
|
-
}
|
|
5633
|
-
var hasOwn$1 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
5634
|
-
/** Collect the property paths where `work` differs from `base`. */
|
|
5635
|
-
function diff(base, work, path, changes, ancestors) {
|
|
5636
|
-
if (ancestors.has(work)) return;
|
|
5637
|
-
ancestors.add(work);
|
|
5638
|
-
for (const key of Object.keys(work)) {
|
|
5639
|
-
const b = base[key];
|
|
5640
|
-
const w = work[key];
|
|
5641
|
-
if (!hasOwn$1(base, key)) changes.push({
|
|
5642
|
-
path: [...path, key],
|
|
5643
|
-
deleted: false,
|
|
5644
|
-
value: w
|
|
5645
|
-
});
|
|
5646
|
-
else if (Object.is(b, w)) continue;
|
|
5647
|
-
else if (isMergeable(b) && isMergeable(w) && Object.getPrototypeOf(b) === Object.getPrototypeOf(w)) diff(b, w, [...path, key], changes, ancestors);
|
|
5648
|
-
else if (!deepEqual(b, w)) changes.push({
|
|
5649
|
-
path: [...path, key],
|
|
5650
|
-
deleted: false,
|
|
5651
|
-
value: w
|
|
5652
|
-
});
|
|
5653
|
-
}
|
|
5654
|
-
for (const key of Object.keys(base)) if (!hasOwn$1(work, key)) changes.push({
|
|
5655
|
-
path: [...path, key],
|
|
5656
|
-
deleted: true
|
|
5657
|
-
});
|
|
5658
|
-
ancestors.delete(work);
|
|
5659
|
-
}
|
|
5660
|
-
/** Whether `ns` already holds what `change` would write. */
|
|
5661
|
-
function isApplied(ns, change) {
|
|
5662
|
-
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
5663
|
-
if (parent === null || typeof parent !== "object") return change.deleted;
|
|
5664
|
-
const key = change.path[change.path.length - 1];
|
|
5665
|
-
if (!hasOwn$1(parent, key)) return change.deleted;
|
|
5666
|
-
return !change.deleted && deepEqual(parent[key], change.value);
|
|
5667
|
-
}
|
|
5668
|
-
function applyChange(ns, change) {
|
|
5669
|
-
if (change.deleted) deleteByPath(ns, change.path);
|
|
5670
|
-
else setByPath(ns, change.path, change.value);
|
|
5671
|
-
}
|
|
5672
|
-
/**
|
|
5673
5644
|
* Apply a write that reached the store while mutations execute to their
|
|
5674
5645
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
5675
5646
|
* its commit neither drops it nor reverts it. Called from inside the store
|
|
@@ -5687,7 +5658,7 @@ function mirror(draft, ns, change, scopes = activeScopes) {
|
|
|
5687
5658
|
});
|
|
5688
5659
|
} catch {
|
|
5689
5660
|
const stored = draft[ns][root];
|
|
5690
|
-
if (hasOwn
|
|
5661
|
+
if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
|
|
5691
5662
|
else delete copy[root];
|
|
5692
5663
|
}
|
|
5693
5664
|
}
|
|
@@ -5711,7 +5682,7 @@ function changedPath(ns, segments) {
|
|
|
5711
5682
|
if (!isMergeable(node)) break;
|
|
5712
5683
|
const key = String(segment);
|
|
5713
5684
|
path.push(key);
|
|
5714
|
-
node = hasOwn
|
|
5685
|
+
node = hasOwn(node, key) ? node[key] : void 0;
|
|
5715
5686
|
}
|
|
5716
5687
|
return path;
|
|
5717
5688
|
}
|
|
@@ -5719,7 +5690,7 @@ function changedPath(ns, segments) {
|
|
|
5719
5690
|
function changeTo(ns, path) {
|
|
5720
5691
|
const parent = getByPath(ns, path.slice(0, -1));
|
|
5721
5692
|
const key = path[path.length - 1];
|
|
5722
|
-
return isMergeable(parent) && hasOwn
|
|
5693
|
+
return isMergeable(parent) && hasOwn(parent, key) ? {
|
|
5723
5694
|
path,
|
|
5724
5695
|
deleted: false,
|
|
5725
5696
|
value: parent[key]
|
|
@@ -5735,7 +5706,7 @@ function changeTo(ns, path) {
|
|
|
5735
5706
|
* replaces exactly what it replaced in program order.
|
|
5736
5707
|
*/
|
|
5737
5708
|
function writtenPaths(before, after, patches) {
|
|
5738
|
-
if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn
|
|
5709
|
+
if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn(before, root) || before[root] !== after[root]).map((root) => changeTo(after, [root]));
|
|
5739
5710
|
const seen = /* @__PURE__ */ new Set();
|
|
5740
5711
|
const changes = [];
|
|
5741
5712
|
for (const patch of patches) {
|
|
@@ -5807,7 +5778,7 @@ function routeStoreUpdate(recipe) {
|
|
|
5807
5778
|
try {
|
|
5808
5779
|
applyChange(namespaces[ns], owned(change));
|
|
5809
5780
|
} catch {
|
|
5810
|
-
if (hasOwn
|
|
5781
|
+
if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
5811
5782
|
else delete namespaces[ns][root];
|
|
5812
5783
|
}
|
|
5813
5784
|
mirror(namespaces, ns, change);
|
|
@@ -5838,11 +5809,7 @@ function commitScopes(commits) {
|
|
|
5838
5809
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
5839
5810
|
scope,
|
|
5840
5811
|
keepRunning,
|
|
5841
|
-
changes: NAMESPACES.map((ns) =>
|
|
5842
|
-
const list = [];
|
|
5843
|
-
diff(scope.base[ns], scope.work[ns], [], list, /* @__PURE__ */ new Set());
|
|
5844
|
-
return [ns, list];
|
|
5845
|
-
})
|
|
5812
|
+
changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
|
|
5846
5813
|
}));
|
|
5847
5814
|
const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
|
|
5848
5815
|
if (changed.length === 0) return;
|
|
@@ -5890,12 +5857,7 @@ var running = () => activeScopes.map((scope) => ({
|
|
|
5890
5857
|
*/
|
|
5891
5858
|
function resync(scope) {
|
|
5892
5859
|
const state = useStoryStore.getState();
|
|
5893
|
-
for (const ns of NAMESPACES)
|
|
5894
|
-
const work = scope.work[ns];
|
|
5895
|
-
const stored = state[ns];
|
|
5896
|
-
for (const key of Object.keys(work)) if (!hasOwn$1(stored, key)) delete work[key];
|
|
5897
|
-
for (const key of Object.keys(stored)) if (!hasOwn$1(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
|
|
5898
|
-
}
|
|
5860
|
+
for (const ns of NAMESPACES) mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
|
|
5899
5861
|
scope.base = cloneNamespaces(state);
|
|
5900
5862
|
}
|
|
5901
5863
|
/**
|
|
@@ -5934,7 +5896,7 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
|
|
|
5934
5896
|
keepRunning: false
|
|
5935
5897
|
}]);
|
|
5936
5898
|
for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
|
|
5937
|
-
for (const key of Object.keys(mergedLocals)) if (!hasOwn
|
|
5899
|
+
for (const key of Object.keys(mergedLocals)) if (!hasOwn(localsClone, key)) scopeUpdate(key, void 0);
|
|
5938
5900
|
}
|
|
5939
5901
|
//#endregion
|
|
5940
5902
|
//#region src/triggers.ts
|
|
@@ -6164,7 +6126,7 @@ function clonePatches(patches) {
|
|
|
6164
6126
|
function computeVarPatches(prev, curr) {
|
|
6165
6127
|
const [, forward, inverse] = produceWithPatches(prev, (draft) => {
|
|
6166
6128
|
const d = draft;
|
|
6167
|
-
for (const key of Object.keys(d)) if (!
|
|
6129
|
+
for (const key of Object.keys(d)) if (!hasOwn(curr, key)) delete d[key];
|
|
6168
6130
|
for (const [key, val] of Object.entries(curr)) d[key] = val;
|
|
6169
6131
|
});
|
|
6170
6132
|
return {
|
|
@@ -6181,6 +6143,26 @@ function rerecordNewestMoment(vars) {
|
|
|
6181
6143
|
if (last < 0) variableBase = vars;
|
|
6182
6144
|
else patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
|
|
6183
6145
|
}
|
|
6146
|
+
/**
|
|
6147
|
+
* Call `fn` with the variables recorded for each of the first `count`
|
|
6148
|
+
* history moments, replaying the patches once.
|
|
6149
|
+
*/
|
|
6150
|
+
function forEachRecorded(count, fn) {
|
|
6151
|
+
let vars = variableBase;
|
|
6152
|
+
for (let i = 0; i < count; i++) {
|
|
6153
|
+
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
|
|
6154
|
+
fn(vars, i);
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
/** A history moment as saves and the session hold it, with its variables. */
|
|
6158
|
+
function savedMoment(moment, variables) {
|
|
6159
|
+
return {
|
|
6160
|
+
passage: moment.passage,
|
|
6161
|
+
variables,
|
|
6162
|
+
timestamp: moment.timestamp,
|
|
6163
|
+
prng: moment.prng
|
|
6164
|
+
};
|
|
6165
|
+
}
|
|
6184
6166
|
/** Reconstruct variables at a given history moment by replaying patches. */
|
|
6185
6167
|
function reconstructVarsAt(index) {
|
|
6186
6168
|
let vars = variableBase;
|
|
@@ -6194,24 +6176,10 @@ function persistSession(get) {
|
|
|
6194
6176
|
if (serializedHistory.length > history.length) serializedHistory.length = history.length;
|
|
6195
6177
|
if (serializedHistory.length < history.length) if (history.length - serializedHistory.length === 1) {
|
|
6196
6178
|
const i = history.length - 1;
|
|
6197
|
-
serializedHistory[i] =
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
prng: history[i].prng
|
|
6202
|
-
};
|
|
6203
|
-
} else {
|
|
6204
|
-
let vars = variableBase;
|
|
6205
|
-
for (let i = 0; i < history.length; i++) {
|
|
6206
|
-
if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
|
|
6207
|
-
if (i >= serializedHistory.length) serializedHistory[i] = {
|
|
6208
|
-
passage: history[i].passage,
|
|
6209
|
-
variables: serialize(vars),
|
|
6210
|
-
timestamp: history[i].timestamp,
|
|
6211
|
-
prng: history[i].prng
|
|
6212
|
-
};
|
|
6213
|
-
}
|
|
6214
|
-
}
|
|
6179
|
+
serializedHistory[i] = savedMoment(history[i], serialize(variables));
|
|
6180
|
+
} else forEachRecorded(history.length, (vars, i) => {
|
|
6181
|
+
if (i >= serializedHistory.length) serializedHistory[i] = savedMoment(history[i], serialize(vars));
|
|
6182
|
+
});
|
|
6215
6183
|
saveSession(storyData.ifid, {
|
|
6216
6184
|
passage: currentPassage,
|
|
6217
6185
|
variables: serialize(variables),
|
|
@@ -6283,6 +6251,58 @@ function resetModuleState(base) {
|
|
|
6283
6251
|
serializedHistory = [];
|
|
6284
6252
|
}
|
|
6285
6253
|
/**
|
|
6254
|
+
* Fresh variables from `defaults` for a game that starts over (init,
|
|
6255
|
+
* restart), recorded as the history base.
|
|
6256
|
+
*/
|
|
6257
|
+
function startVariables(defaults) {
|
|
6258
|
+
const initialVars = createNamespace(deepClone(defaults));
|
|
6259
|
+
resetModuleState(deepClone(initialVars));
|
|
6260
|
+
return initialVars;
|
|
6261
|
+
}
|
|
6262
|
+
/**
|
|
6263
|
+
* Enter the start passage with `variables`, as the only history moment
|
|
6264
|
+
* (init, restart). Call it inside a store update.
|
|
6265
|
+
*/
|
|
6266
|
+
function enterStart(state, passage, variables, transientDefaults) {
|
|
6267
|
+
state.currentPassage = passage;
|
|
6268
|
+
state.navigationId++;
|
|
6269
|
+
state.variables = variables;
|
|
6270
|
+
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6271
|
+
state.temporary = createNamespace();
|
|
6272
|
+
state.history = [{
|
|
6273
|
+
passage,
|
|
6274
|
+
timestamp: Date.now()
|
|
6275
|
+
}];
|
|
6276
|
+
state.historyIndex = 0;
|
|
6277
|
+
}
|
|
6278
|
+
/**
|
|
6279
|
+
* Move through history by `step` moments (back or forward), restoring the
|
|
6280
|
+
* snapshot recorded for the moment moved to: live variables may hold edits
|
|
6281
|
+
* made since the current moment was recorded.
|
|
6282
|
+
*/
|
|
6283
|
+
function moveInHistory(get, set, step) {
|
|
6284
|
+
const { historyIndex, history } = get();
|
|
6285
|
+
const target = historyIndex + step;
|
|
6286
|
+
if (target < 0 || target >= history.length) return;
|
|
6287
|
+
finishEnteredMoment(get, set);
|
|
6288
|
+
const previousPassage = get().currentPassage;
|
|
6289
|
+
const targetPassage = history[target].passage;
|
|
6290
|
+
emit("beforenavigate", targetPassage);
|
|
6291
|
+
const restoredVars = deepClone(reconstructVarsAt(target));
|
|
6292
|
+
set((state) => {
|
|
6293
|
+
state.historyIndex += step;
|
|
6294
|
+
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6295
|
+
state.navigationId++;
|
|
6296
|
+
state.variables = restoredVars;
|
|
6297
|
+
state.temporary = createNamespace();
|
|
6298
|
+
});
|
|
6299
|
+
reinitTriggerState();
|
|
6300
|
+
lastNavigationVars = get().variables;
|
|
6301
|
+
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6302
|
+
persistSession(get);
|
|
6303
|
+
emit("afternavigate", targetPassage, previousPassage);
|
|
6304
|
+
}
|
|
6305
|
+
/**
|
|
6286
6306
|
* Record the state StoryInit left behind as the start moment: its variable
|
|
6287
6307
|
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
6288
6308
|
* after the StoryInit passage has rendered, and again after the `storyinit`
|
|
@@ -6293,10 +6313,7 @@ function resetModuleState(base) {
|
|
|
6293
6313
|
function recordStoryInitState() {
|
|
6294
6314
|
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
6295
6315
|
if (history.length !== 1 || historyIndex !== 0) return;
|
|
6296
|
-
|
|
6297
|
-
patchEntries = [];
|
|
6298
|
-
lastNavigationVars = variables;
|
|
6299
|
-
serializedHistory = [];
|
|
6316
|
+
resetModuleState(variables);
|
|
6300
6317
|
const prng = snapshotPRNG();
|
|
6301
6318
|
useStoryStore.setState((state) => {
|
|
6302
6319
|
state.history[0].prng = prng;
|
|
@@ -6466,56 +6483,31 @@ function keepHookWrites(payload, before, after) {
|
|
|
6466
6483
|
if (!moment) return;
|
|
6467
6484
|
mergeHookWrites(moment.variables, before, after, /* @__PURE__ */ new Set());
|
|
6468
6485
|
}
|
|
6469
|
-
var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
6470
|
-
/**
|
|
6471
|
-
* Whether changes between `a` and `b` merge key by key: both are arrays,
|
|
6472
|
-
* or both are objects of one class (see isMergeable).
|
|
6473
|
-
*/
|
|
6474
|
-
function mergeable(a, b) {
|
|
6475
|
-
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b);
|
|
6476
|
-
return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
6477
|
-
}
|
|
6478
|
-
/**
|
|
6479
|
-
* Apply the hooks' change of a value, from `before` to `after`, to the
|
|
6480
|
-
* snapshot's copy of it, `target[key]`. Store updates are immutable, so a
|
|
6481
|
-
* value no hook touched keeps its identity; one rebuilt with equal content
|
|
6482
|
-
* (mutation code commits whole values) is no change either. Where `target`
|
|
6483
|
-
* lacks the key or holds another kind of value (the passage created or
|
|
6484
|
-
* replaced it), the hooks' whole value is written.
|
|
6485
|
-
*/
|
|
6486
|
-
function mergeHookWrite(target, key, before, after, ancestors) {
|
|
6487
|
-
if (Object.is(before, after)) return;
|
|
6488
|
-
if (mergeable(before, after) && hasOwn(target, key) && mergeable(after, target[key]) && !ancestors.has(after)) mergeHookWrites(target[key], before, after, ancestors);
|
|
6489
|
-
else if (!deepEqual(before, after)) target[key] = deepClone(after);
|
|
6490
|
-
}
|
|
6491
6486
|
/**
|
|
6492
|
-
* Apply the changes between `before` and `after`, two objects or two
|
|
6493
|
-
*
|
|
6494
|
-
* index.
|
|
6495
|
-
*
|
|
6496
|
-
*
|
|
6497
|
-
*
|
|
6487
|
+
* Apply the hooks' changes between `before` and `after`, two objects or two
|
|
6488
|
+
* arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
|
|
6489
|
+
* elements merge by index). A changed value is merged key by key where the
|
|
6490
|
+
* snapshot holds the same kind of value. Where it lacks the key or holds
|
|
6491
|
+
* another kind (the passage created or replaced it), the hooks' whole value
|
|
6492
|
+
* is written.
|
|
6498
6493
|
*/
|
|
6499
6494
|
function mergeHookWrites(target, before, after, ancestors) {
|
|
6500
6495
|
ancestors.add(after);
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
if (a.length < b.length) target.length = Math.min(target.length, a.length);
|
|
6507
|
-
for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
|
|
6508
|
-
} else {
|
|
6509
|
-
for (const key of Object.keys(before)) if (!hasOwn(after, key)) delete target[key];
|
|
6510
|
-
for (const key of Object.keys(after)) if (hasOwn(before, key)) mergeHookWrite(target, key, before[key], after[key], ancestors);
|
|
6511
|
-
else target[key] = deepClone(after[key]);
|
|
6512
|
-
}
|
|
6496
|
+
mergeKeys(target, before, after, (key, b, a) => {
|
|
6497
|
+
if (!hasOwn(target, key) || !mergesWith(a, target[key], true) || ancestors.has(a)) return false;
|
|
6498
|
+
mergeHookWrites(target[key], b, a, ancestors);
|
|
6499
|
+
return true;
|
|
6500
|
+
});
|
|
6513
6501
|
ancestors.delete(after);
|
|
6514
6502
|
}
|
|
6503
|
+
/** Restore the PRNG from a snapshot, or reset it without one. */
|
|
6504
|
+
function restorePRNGFrom(prng) {
|
|
6505
|
+
if (prng) restorePRNG(prng.seed, prng.pull);
|
|
6506
|
+
else resetPRNG();
|
|
6507
|
+
}
|
|
6515
6508
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
6516
6509
|
function restorePRNGFromMoment(moment) {
|
|
6517
|
-
if (moment
|
|
6518
|
-
else if (moment) resetPRNG();
|
|
6510
|
+
if (moment) restorePRNGFrom(moment.prng);
|
|
6519
6511
|
}
|
|
6520
6512
|
var NAMESPACE_KEYS = [
|
|
6521
6513
|
"variables",
|
|
@@ -6598,6 +6590,46 @@ function handled(p) {
|
|
|
6598
6590
|
p.catch(() => {});
|
|
6599
6591
|
return p;
|
|
6600
6592
|
}
|
|
6593
|
+
/**
|
|
6594
|
+
* `p` with its failure logged under `message` and passed to `onError`, and
|
|
6595
|
+
* still rejecting; marked as handled (see handled()).
|
|
6596
|
+
*/
|
|
6597
|
+
function reported(p, message, onError) {
|
|
6598
|
+
return handled(p.catch((err) => {
|
|
6599
|
+
console.error(message, err);
|
|
6600
|
+
onError?.(err);
|
|
6601
|
+
throw err;
|
|
6602
|
+
}));
|
|
6603
|
+
}
|
|
6604
|
+
/** Record in the slot cache whether `slot` holds a save. */
|
|
6605
|
+
function recordKnownSave(set, slot, known) {
|
|
6606
|
+
set((state) => {
|
|
6607
|
+
const key = slot ?? "";
|
|
6608
|
+
if (known) state.knownSaves = {
|
|
6609
|
+
...state.knownSaves,
|
|
6610
|
+
[key]: true
|
|
6611
|
+
};
|
|
6612
|
+
else {
|
|
6613
|
+
const { [key]: _, ...rest } = state.knownSaves;
|
|
6614
|
+
state.knownSaves = rest;
|
|
6615
|
+
}
|
|
6616
|
+
});
|
|
6617
|
+
}
|
|
6618
|
+
/**
|
|
6619
|
+
* Queue the clearing of saved data, then restart now: the new playthrough
|
|
6620
|
+
* is stored after it, and operations issued from here on belong to the new
|
|
6621
|
+
* game. The slot cache empties once the clearing is done, after operations
|
|
6622
|
+
* issued before it have updated it.
|
|
6623
|
+
*/
|
|
6624
|
+
function clearAndRestart(get, set, clearing, message) {
|
|
6625
|
+
const cleared = clearing.then(() => {
|
|
6626
|
+
set((state) => {
|
|
6627
|
+
state.knownSaves = {};
|
|
6628
|
+
});
|
|
6629
|
+
});
|
|
6630
|
+
get().restart();
|
|
6631
|
+
return reported(cleared, message);
|
|
6632
|
+
}
|
|
6601
6633
|
var useStoryStore = create()(storyStore((set, get) => ({
|
|
6602
6634
|
storyData: null,
|
|
6603
6635
|
currentPassage: "",
|
|
@@ -6643,23 +6675,13 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6643
6675
|
init: (storyData, variableDefaults = {}, transientDefaults = {}) => {
|
|
6644
6676
|
const startPassage = storyData.passagesById.get(storyData.startNode);
|
|
6645
6677
|
if (!startPassage) throw new Error(`spindle: Start passage (pid=${storyData.startNode}) not found.`);
|
|
6646
|
-
const initialVars =
|
|
6647
|
-
resetModuleState(deepClone(initialVars));
|
|
6678
|
+
const initialVars = startVariables(variableDefaults);
|
|
6648
6679
|
set((state) => {
|
|
6649
6680
|
state.storyData = storyData;
|
|
6650
6681
|
state.playthroughId = "";
|
|
6651
|
-
state.currentPassage = startPassage.name;
|
|
6652
|
-
state.navigationId++;
|
|
6653
|
-
state.variables = initialVars;
|
|
6654
6682
|
state.variableDefaults = variableDefaults;
|
|
6655
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6656
6683
|
state.transientDefaults = transientDefaults;
|
|
6657
|
-
state.
|
|
6658
|
-
state.history = [{
|
|
6659
|
-
passage: startPassage.name,
|
|
6660
|
-
timestamp: Date.now()
|
|
6661
|
-
}];
|
|
6662
|
-
state.historyIndex = 0;
|
|
6684
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
6663
6685
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
6664
6686
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
6665
6687
|
});
|
|
@@ -6728,48 +6750,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6728
6750
|
emit("afternavigate", passageName, previousPassage);
|
|
6729
6751
|
for (const next of deferred) get().navigate(next);
|
|
6730
6752
|
},
|
|
6731
|
-
goBack: () =>
|
|
6732
|
-
|
|
6733
|
-
if (historyIndex <= 0) return;
|
|
6734
|
-
finishEnteredMoment(get, set);
|
|
6735
|
-
const previousPassage = get().currentPassage;
|
|
6736
|
-
const targetPassage = get().history[historyIndex - 1].passage;
|
|
6737
|
-
emit("beforenavigate", targetPassage);
|
|
6738
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
6739
|
-
set((state) => {
|
|
6740
|
-
state.historyIndex--;
|
|
6741
|
-
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6742
|
-
state.navigationId++;
|
|
6743
|
-
state.variables = restoredVars;
|
|
6744
|
-
state.temporary = createNamespace();
|
|
6745
|
-
});
|
|
6746
|
-
reinitTriggerState();
|
|
6747
|
-
lastNavigationVars = get().variables;
|
|
6748
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6749
|
-
persistSession(get);
|
|
6750
|
-
emit("afternavigate", targetPassage, previousPassage);
|
|
6751
|
-
},
|
|
6752
|
-
goForward: () => {
|
|
6753
|
-
const { historyIndex, history: hist } = get();
|
|
6754
|
-
if (historyIndex >= hist.length - 1) return;
|
|
6755
|
-
finishEnteredMoment(get, set);
|
|
6756
|
-
const previousPassage = get().currentPassage;
|
|
6757
|
-
const targetPassage = hist[historyIndex + 1].passage;
|
|
6758
|
-
emit("beforenavigate", targetPassage);
|
|
6759
|
-
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
6760
|
-
set((state) => {
|
|
6761
|
-
state.historyIndex++;
|
|
6762
|
-
state.currentPassage = state.history[state.historyIndex].passage;
|
|
6763
|
-
state.navigationId++;
|
|
6764
|
-
state.variables = restoredVars;
|
|
6765
|
-
state.temporary = createNamespace();
|
|
6766
|
-
});
|
|
6767
|
-
reinitTriggerState();
|
|
6768
|
-
lastNavigationVars = get().variables;
|
|
6769
|
-
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
6770
|
-
persistSession(get);
|
|
6771
|
-
emit("afternavigate", targetPassage, previousPassage);
|
|
6772
|
-
},
|
|
6753
|
+
goBack: () => moveInHistory(get, set, -1),
|
|
6754
|
+
goForward: () => moveInHistory(get, set, 1),
|
|
6773
6755
|
setVariable: (name, value) => {
|
|
6774
6756
|
set((state) => {
|
|
6775
6757
|
checkVariableName(name, `$${name}`);
|
|
@@ -6831,19 +6813,9 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6831
6813
|
cleanupRuntimeHandlers();
|
|
6832
6814
|
resetPRNG();
|
|
6833
6815
|
resetTriggers();
|
|
6834
|
-
const initialVars =
|
|
6835
|
-
resetModuleState(deepClone(initialVars));
|
|
6816
|
+
const initialVars = startVariables(variableDefaults);
|
|
6836
6817
|
set((state) => {
|
|
6837
|
-
state
|
|
6838
|
-
state.navigationId++;
|
|
6839
|
-
state.variables = initialVars;
|
|
6840
|
-
state.transient = createNamespace(deepClone(transientDefaults));
|
|
6841
|
-
state.temporary = createNamespace();
|
|
6842
|
-
state.history = [{
|
|
6843
|
-
passage: startPassage.name,
|
|
6844
|
-
timestamp: Date.now()
|
|
6845
|
-
}];
|
|
6846
|
-
state.historyIndex = 0;
|
|
6818
|
+
enterStart(state, startPassage.name, initialVars, transientDefaults);
|
|
6847
6819
|
state.visitCounts = createCounts(null, startPassage.name);
|
|
6848
6820
|
state.renderCounts = createCounts(null, startPassage.name);
|
|
6849
6821
|
if (!keepDeferred) state.renderDeferred = false;
|
|
@@ -6859,23 +6831,14 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6859
6831
|
const { storyData } = get();
|
|
6860
6832
|
if (!storyData) return Promise.resolve();
|
|
6861
6833
|
const playthrough = resolvePlaythroughId();
|
|
6862
|
-
return
|
|
6834
|
+
return reported(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
6863
6835
|
set((state) => {
|
|
6864
6836
|
state.saveError = null;
|
|
6865
6837
|
});
|
|
6866
6838
|
await quickSave(storyData.ifid, playthrough, payload, slot, custom);
|
|
6867
|
-
set
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
[slot ?? ""]: true
|
|
6871
|
-
};
|
|
6872
|
-
});
|
|
6873
|
-
}).catch((err) => {
|
|
6874
|
-
console.error("spindle: failed to save", err);
|
|
6875
|
-
set((state) => {
|
|
6876
|
-
state.saveError = errorMessage(err, "Failed to save");
|
|
6877
|
-
});
|
|
6878
|
-
throw err;
|
|
6839
|
+
recordKnownSave(set, slot, true);
|
|
6840
|
+
}), "spindle: failed to save", (err) => set((state) => {
|
|
6841
|
+
state.saveError = errorMessage(err, "Failed to save");
|
|
6879
6842
|
}));
|
|
6880
6843
|
},
|
|
6881
6844
|
load: (slot) => {
|
|
@@ -6888,24 +6851,20 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6888
6851
|
const read = loadSlotSave(storyData.ifid, slot);
|
|
6889
6852
|
const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
|
|
6890
6853
|
const replacement = ++stateReplacementsIssued;
|
|
6891
|
-
return
|
|
6854
|
+
return reported(read.then(async (loaded) => {
|
|
6892
6855
|
await switched;
|
|
6893
6856
|
if (!loaded) return;
|
|
6894
6857
|
if (latestStateApplied > replacement) return;
|
|
6895
6858
|
slotLoadApplying = replacement;
|
|
6896
6859
|
get().loadFromPayload(loaded.payload, slot);
|
|
6897
|
-
})
|
|
6898
|
-
|
|
6899
|
-
set((state) => {
|
|
6900
|
-
state.loadError = errorMessage(err, "Failed to load");
|
|
6901
|
-
});
|
|
6902
|
-
throw err;
|
|
6860
|
+
}), "spindle: failed to load save", (err) => set((state) => {
|
|
6861
|
+
state.loadError = errorMessage(err, "Failed to load");
|
|
6903
6862
|
}));
|
|
6904
6863
|
},
|
|
6905
6864
|
hasSave: (slot) => {
|
|
6906
6865
|
const { storyData, knownSaves } = get();
|
|
6907
6866
|
if (!storyData) return false;
|
|
6908
|
-
return
|
|
6867
|
+
return hasOwn(knownSaves, slot ?? "");
|
|
6909
6868
|
},
|
|
6910
6869
|
getSaveInfo: async (slot) => {
|
|
6911
6870
|
const { storyData } = get();
|
|
@@ -6920,16 +6879,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6920
6879
|
deleteSave: (slot) => {
|
|
6921
6880
|
const { storyData } = get();
|
|
6922
6881
|
if (!storyData) return Promise.resolve();
|
|
6923
|
-
return
|
|
6924
|
-
set((state) => {
|
|
6925
|
-
const key = slot ?? "";
|
|
6926
|
-
const { [key]: _, ...rest } = state.knownSaves;
|
|
6927
|
-
state.knownSaves = rest;
|
|
6928
|
-
});
|
|
6929
|
-
}).catch((err) => {
|
|
6930
|
-
console.error("spindle: failed to delete save", err);
|
|
6931
|
-
throw err;
|
|
6932
|
-
}));
|
|
6882
|
+
return reported(deleteSlotSave(storyData.ifid, slot).then(() => recordKnownSave(set, slot, false)), "spindle: failed to delete save");
|
|
6933
6883
|
},
|
|
6934
6884
|
exportSave: async (slot) => {
|
|
6935
6885
|
const { storyData } = get();
|
|
@@ -6941,40 +6891,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6941
6891
|
if (!storyData) throw new Error("spindle: Story is not initialized.");
|
|
6942
6892
|
if (!isSaveExport(data)) throw new Error("Invalid save file format");
|
|
6943
6893
|
const info = await importSlotSave(data, storyData.ifid, slot);
|
|
6944
|
-
set
|
|
6945
|
-
state.knownSaves = {
|
|
6946
|
-
...state.knownSaves,
|
|
6947
|
-
[slot ?? ""]: true
|
|
6948
|
-
};
|
|
6949
|
-
});
|
|
6894
|
+
recordKnownSave(set, slot, true);
|
|
6950
6895
|
return info;
|
|
6951
6896
|
},
|
|
6952
6897
|
clearGameData: () => {
|
|
6953
6898
|
const { storyData } = get();
|
|
6954
6899
|
if (!storyData) return Promise.resolve();
|
|
6955
|
-
|
|
6956
|
-
set((state) => {
|
|
6957
|
-
state.knownSaves = {};
|
|
6958
|
-
});
|
|
6959
|
-
});
|
|
6960
|
-
get().restart();
|
|
6961
|
-
return handled(cleared.catch((err) => {
|
|
6962
|
-
console.error("spindle: failed to clear game data", err);
|
|
6963
|
-
throw err;
|
|
6964
|
-
}));
|
|
6965
|
-
},
|
|
6966
|
-
clearAllData: () => {
|
|
6967
|
-
const cleared = clearAllData().then(() => {
|
|
6968
|
-
set((state) => {
|
|
6969
|
-
state.knownSaves = {};
|
|
6970
|
-
});
|
|
6971
|
-
});
|
|
6972
|
-
get().restart();
|
|
6973
|
-
return handled(cleared.catch((err) => {
|
|
6974
|
-
console.error("spindle: failed to clear all data", err);
|
|
6975
|
-
throw err;
|
|
6976
|
-
}));
|
|
6900
|
+
return clearAndRestart(get, set, clearGameData(storyData.ifid), "spindle: failed to clear game data");
|
|
6977
6901
|
},
|
|
6902
|
+
clearAllData: () => clearAndRestart(get, set, clearAllData(), "spindle: failed to clear all data"),
|
|
6978
6903
|
deletePlaythrough: (playthroughId) => {
|
|
6979
6904
|
const { storyData } = get();
|
|
6980
6905
|
if (!storyData) return Promise.resolve();
|
|
@@ -6988,29 +6913,19 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
6988
6913
|
});
|
|
6989
6914
|
if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
|
|
6990
6915
|
else if (current === "") switchToLookedUpPlaythrough(deletion.then((replaced) => replaced ? replacementId : established, () => established));
|
|
6991
|
-
return
|
|
6916
|
+
return reported(deletion.then(async () => {
|
|
6992
6917
|
const known = await populateKnownSaves(storyData.ifid);
|
|
6993
6918
|
set((state) => {
|
|
6994
6919
|
state.knownSaves = known;
|
|
6995
6920
|
});
|
|
6996
|
-
})
|
|
6997
|
-
console.error("spindle: failed to delete playthrough", err);
|
|
6998
|
-
throw err;
|
|
6999
|
-
}));
|
|
6921
|
+
}), "spindle: failed to delete playthrough");
|
|
7000
6922
|
},
|
|
7001
6923
|
getSavePayload: () => {
|
|
7002
6924
|
const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
|
|
7003
6925
|
const saveHistory = [];
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
saveHistory.push({
|
|
7008
|
-
passage: history[i].passage,
|
|
7009
|
-
variables: plainCopy(vars),
|
|
7010
|
-
timestamp: history[i].timestamp,
|
|
7011
|
-
prng: history[i].prng
|
|
7012
|
-
});
|
|
7013
|
-
}
|
|
6926
|
+
forEachRecorded(history.length, (vars, i) => {
|
|
6927
|
+
saveHistory.push(savedMoment(history[i], plainCopy(vars)));
|
|
6928
|
+
});
|
|
7014
6929
|
return {
|
|
7015
6930
|
passage: currentPassage,
|
|
7016
6931
|
variables: plainCopy(variables),
|
|
@@ -7051,12 +6966,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
7051
6966
|
}
|
|
7052
6967
|
variableBase = deepClone(base);
|
|
7053
6968
|
patchEntries = newPatchEntries;
|
|
7054
|
-
serializedHistory = payload.history.map((m) => (
|
|
7055
|
-
passage: m.passage,
|
|
7056
|
-
variables: serialize(m.variables),
|
|
7057
|
-
timestamp: m.timestamp,
|
|
7058
|
-
prng: m.prng
|
|
7059
|
-
}));
|
|
6969
|
+
serializedHistory = payload.history.map((m) => savedMoment(m, serialize(m.variables)));
|
|
7060
6970
|
set((state) => {
|
|
7061
6971
|
state.currentPassage = payload.passage;
|
|
7062
6972
|
state.navigationId++;
|
|
@@ -7075,9 +6985,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
7075
6985
|
});
|
|
7076
6986
|
reinitTriggerState();
|
|
7077
6987
|
lastNavigationVars = reconstructVarsAt(get().historyIndex);
|
|
7078
|
-
|
|
7079
|
-
if (prng) restorePRNG(prng.seed, prng.pull);
|
|
7080
|
-
else resetPRNG();
|
|
6988
|
+
restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
|
|
7081
6989
|
persistSession(get);
|
|
7082
6990
|
withoutDraws(() => emit("afterload", slot));
|
|
7083
6991
|
},
|
|
@@ -7443,7 +7351,7 @@ function loadFromStorage() {
|
|
|
7443
7351
|
/** Register a setting, starting from its default unless it has a value. */
|
|
7444
7352
|
function define(name, def) {
|
|
7445
7353
|
definitions.set(name, def);
|
|
7446
|
-
if (!hasOwn
|
|
7354
|
+
if (!hasOwn(values, name)) values[name] = def.config.default;
|
|
7447
7355
|
loadFromStorage();
|
|
7448
7356
|
}
|
|
7449
7357
|
/** The value of setting `name` if it has the type of `fallback`, else that. */
|
|
@@ -7620,18 +7528,236 @@ function collectText(nodes) {
|
|
|
7620
7528
|
return nodes.map((n) => n.type === "text" ? n.value : "").join("");
|
|
7621
7529
|
}
|
|
7622
7530
|
//#endregion
|
|
7531
|
+
//#region src/utils/parse-delay.ts
|
|
7532
|
+
/**
|
|
7533
|
+
* Parse a delay string into milliseconds.
|
|
7534
|
+
* Supports: "2s" → 2000, "500ms" → 500, bare number → ms
|
|
7535
|
+
*/
|
|
7536
|
+
function parseDelay(str) {
|
|
7537
|
+
const trimmed = str.trim();
|
|
7538
|
+
if (trimmed.endsWith("ms")) return parseFloat(trimmed.slice(0, -2));
|
|
7539
|
+
if (trimmed.endsWith("s")) return parseFloat(trimmed.slice(0, -1)) * 1e3;
|
|
7540
|
+
return parseFloat(trimmed);
|
|
7541
|
+
}
|
|
7542
|
+
//#endregion
|
|
7543
|
+
//#region src/components/macros/macro-args.ts
|
|
7544
|
+
/**
|
|
7545
|
+
* The macro argument layer: reads a macro's raw argument text into the
|
|
7546
|
+
* parameters its definition declares (see ParameterDef), so every macro
|
|
7547
|
+
* splits and unquotes its arguments the same way. defineMacro() passes the
|
|
7548
|
+
* result to the macro as `ctx.args`.
|
|
7549
|
+
*
|
|
7550
|
+
* Arguments are read in groups, split at `separator` parameters. A group's
|
|
7551
|
+
* `flag` parameters are taken off its start or end first. Its last
|
|
7552
|
+
* `expression` or `text` parameter (without one, its last parameter) reads
|
|
7553
|
+
* the text the others leave; each parameter before it reads one term from
|
|
7554
|
+
* the start, and each after it one term from the end. Terms are separated by
|
|
7555
|
+
* whitespace outside literals and brackets (see arg-utils.ts), and a term
|
|
7556
|
+
* that follows an operator belongs to the expression before it. A parameter
|
|
7557
|
+
* with nothing to read is unset (a flag or separator is false, options are
|
|
7558
|
+
* empty).
|
|
7559
|
+
*/
|
|
7560
|
+
/** The terms of `src`: the runs of text between its depth-0 whitespace. */
|
|
7561
|
+
function terms(src) {
|
|
7562
|
+
const spans = [];
|
|
7563
|
+
let start = 0;
|
|
7564
|
+
for (const i of [...topLevelIndices(src, isWhitespace), src.length]) {
|
|
7565
|
+
if (i > start) spans.push([start, i]);
|
|
7566
|
+
start = i + 1;
|
|
7567
|
+
}
|
|
7568
|
+
return spans;
|
|
7569
|
+
}
|
|
7570
|
+
var slice = (src, [start, end]) => src.slice(start, end);
|
|
7571
|
+
/**
|
|
7572
|
+
* The value of a `text` argument: one quoted string, or the text with any
|
|
7573
|
+
* loose quotes stripped (`"Red` → `Red`).
|
|
7574
|
+
*/
|
|
7575
|
+
function readText(src) {
|
|
7576
|
+
return readWholeQuoted(src) ?? stripLooseQuotes(src);
|
|
7577
|
+
}
|
|
7578
|
+
function readValue(param, src) {
|
|
7579
|
+
switch (param.type) {
|
|
7580
|
+
case "string": return readWholeQuoted(src) ?? void 0;
|
|
7581
|
+
case "text": return readText(src);
|
|
7582
|
+
case "names": return src.split(",").map((name) => name.trim());
|
|
7583
|
+
case "delay": return parseDelay(src);
|
|
7584
|
+
case "number": return Number(src);
|
|
7585
|
+
case "options": return readOptions(param.parameters ?? [], src);
|
|
7586
|
+
default: return src;
|
|
7587
|
+
}
|
|
7588
|
+
}
|
|
7589
|
+
function unset(param) {
|
|
7590
|
+
if (param.type === "flag" || param.type === "separator") return false;
|
|
7591
|
+
return param.type === "options" ? {} : void 0;
|
|
7592
|
+
}
|
|
7593
|
+
var WORD_RE = /\w+/y;
|
|
7594
|
+
var DIGITS_RE = /\d+/y;
|
|
7595
|
+
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
7596
|
+
function matchAt(re, src, pos) {
|
|
7597
|
+
re.lastIndex = pos;
|
|
7598
|
+
return re.exec(src)?.[0] ?? null;
|
|
7599
|
+
}
|
|
7600
|
+
/**
|
|
7601
|
+
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
7602
|
+
* quoted string or digit run after it as its value; keywords that aren't
|
|
7603
|
+
* declared, and their values, are skipped.
|
|
7604
|
+
*/
|
|
7605
|
+
function readOptions(params, src) {
|
|
7606
|
+
const options = {};
|
|
7607
|
+
let i = 0;
|
|
7608
|
+
while (i < src.length) {
|
|
7609
|
+
const key = matchAt(WORD_RE, src, i);
|
|
7610
|
+
if (!key) {
|
|
7611
|
+
i = readQuoted(src, i)?.end ?? i + 1;
|
|
7612
|
+
continue;
|
|
7613
|
+
}
|
|
7614
|
+
i += key.length;
|
|
7615
|
+
let val;
|
|
7616
|
+
let j = i;
|
|
7617
|
+
while (j < src.length && isWhitespace(src[j])) j++;
|
|
7618
|
+
if (j > i) {
|
|
7619
|
+
const quoted = readQuoted(src, j);
|
|
7620
|
+
const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
|
|
7621
|
+
if (quoted) {
|
|
7622
|
+
val = quoted.value;
|
|
7623
|
+
i = quoted.end;
|
|
7624
|
+
} else if (digits) {
|
|
7625
|
+
val = digits;
|
|
7626
|
+
i = j + digits.length;
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
const param = params.find((p) => p.name === key);
|
|
7630
|
+
if (param?.type === "flag") options[key] = true;
|
|
7631
|
+
else if (param?.type === "number") options[key] = Number(val ?? 0);
|
|
7632
|
+
else if (param) options[key] = val;
|
|
7633
|
+
}
|
|
7634
|
+
return options;
|
|
7635
|
+
}
|
|
7636
|
+
/** `inline %name` reads a transient variable; `inline % 2` is modulo. */
|
|
7637
|
+
var LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
|
|
7638
|
+
/**
|
|
7639
|
+
* The rest of `src` without the flag `word` as its last or first term, or
|
|
7640
|
+
* `null` if it has no such flag. Next to a binary operator the word is an
|
|
7641
|
+
* operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
|
|
7642
|
+
* is not one.
|
|
7643
|
+
*/
|
|
7644
|
+
function takeFlag(src, word) {
|
|
7645
|
+
const spans = terms(src);
|
|
7646
|
+
if (spans.length < 2) return null;
|
|
7647
|
+
if (slice(src, spans[spans.length - 1]) === word) {
|
|
7648
|
+
const rest = src.slice(0, spans[spans.length - 2][1]);
|
|
7649
|
+
if (!endsWithOperator(rest)) return rest;
|
|
7650
|
+
}
|
|
7651
|
+
if (slice(src, spans[0]) === word) {
|
|
7652
|
+
const rest = src.slice(spans[1][0]);
|
|
7653
|
+
if (!LEADING_OPERATOR_RE.test(rest)) return rest;
|
|
7654
|
+
}
|
|
7655
|
+
return null;
|
|
7656
|
+
}
|
|
7657
|
+
/**
|
|
7658
|
+
* Where the separator `word` splits `src`, as the end of the text before it
|
|
7659
|
+
* and the start of the text after it, or `null` if it doesn't. A word is a
|
|
7660
|
+
* term with terms on both sides; `=` is an assignment at depth 0, not part
|
|
7661
|
+
* of `==` or `!=`.
|
|
7662
|
+
*/
|
|
7663
|
+
function findSeparator(src, word) {
|
|
7664
|
+
if (word === "=") {
|
|
7665
|
+
const indices = topLevelIndices(src, (ch) => ch === "=");
|
|
7666
|
+
for (let k = 0; k < indices.length; k++) {
|
|
7667
|
+
const i = indices[k];
|
|
7668
|
+
if (src[i + 1] === "=") {
|
|
7669
|
+
k++;
|
|
7670
|
+
continue;
|
|
7671
|
+
}
|
|
7672
|
+
if (src[i - 1] !== "!") return [i, i + 1];
|
|
7673
|
+
}
|
|
7674
|
+
return null;
|
|
7675
|
+
}
|
|
7676
|
+
const spans = terms(src);
|
|
7677
|
+
for (let k = 1; k < spans.length - 1; k++) if (slice(src, spans[k]) === word) return [spans[k - 1][1], spans[k + 1][0]];
|
|
7678
|
+
return null;
|
|
7679
|
+
}
|
|
7680
|
+
var takesRest = (param) => param.type === void 0 || param.type === "expression" || param.type === "text";
|
|
7681
|
+
/** Read the parameters of one group from `src` (`null`: no text) into `args`. */
|
|
7682
|
+
function readGroup(params, src, args) {
|
|
7683
|
+
let text = src?.trim() ?? "";
|
|
7684
|
+
for (const flag of params.filter((p) => p.type === "flag")) {
|
|
7685
|
+
const rest = src === null ? null : takeFlag(text, flag.name);
|
|
7686
|
+
args[flag.name] = rest !== null;
|
|
7687
|
+
text = rest ?? text;
|
|
7688
|
+
}
|
|
7689
|
+
const positional = params.filter((p) => p.type !== "flag");
|
|
7690
|
+
if (positional.length === 0) return;
|
|
7691
|
+
const lastText = positional.findIndex((p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest));
|
|
7692
|
+
const restIndex = lastText < 0 ? positional.length - 1 : lastText;
|
|
7693
|
+
const read = (param, value) => value === "" ? unset(param) : readValue(param, value);
|
|
7694
|
+
if (positional.length === 1) {
|
|
7695
|
+
args[positional[0].name] = read(positional[0], text);
|
|
7696
|
+
return;
|
|
7697
|
+
}
|
|
7698
|
+
const spans = terms(text);
|
|
7699
|
+
let from = 0;
|
|
7700
|
+
let to = spans.length;
|
|
7701
|
+
for (const param of positional.slice(0, restIndex)) args[param.name] = from < to ? read(param, slice(text, spans[from++])) : unset(param);
|
|
7702
|
+
for (const param of positional.slice(restIndex + 1).reverse()) {
|
|
7703
|
+
const value = from < to && !endsWithOperator(text.slice(0, spans[to - 1][0])) ? read(param, slice(text, spans[to - 1])) : void 0;
|
|
7704
|
+
args[param.name] = value ?? unset(param);
|
|
7705
|
+
if (value !== void 0) to--;
|
|
7706
|
+
}
|
|
7707
|
+
const rest = positional[restIndex];
|
|
7708
|
+
args[rest.name] = from < to ? read(rest, text.slice(spans[from][0], spans[to - 1][1])) : unset(rest);
|
|
7709
|
+
}
|
|
7710
|
+
/** Read `rawArgs` into the declared `parameters` (see above). */
|
|
7711
|
+
function parseMacroArgs(rawArgs, parameters) {
|
|
7712
|
+
const args = {};
|
|
7713
|
+
let rest = rawArgs.trim();
|
|
7714
|
+
let groupStart = 0;
|
|
7715
|
+
parameters.forEach((separator, i) => {
|
|
7716
|
+
if (separator.type !== "separator") return;
|
|
7717
|
+
const at = rest === null ? null : findSeparator(rest, separator.name);
|
|
7718
|
+
args[separator.name] = at !== null;
|
|
7719
|
+
const before = at ? rest.slice(0, at[0]) : rest;
|
|
7720
|
+
readGroup(parameters.slice(groupStart, i), before, args);
|
|
7721
|
+
rest = at ? rest.slice(at[1]) : null;
|
|
7722
|
+
groupStart = i + 1;
|
|
7723
|
+
});
|
|
7724
|
+
readGroup(parameters.slice(groupStart), rest, args);
|
|
7725
|
+
return args;
|
|
7726
|
+
}
|
|
7727
|
+
/**
|
|
7728
|
+
* The passage a passage-name argument names: the value of its expression
|
|
7729
|
+
* or, when that can't be evaluated (`{goto Bob's room}`), its text.
|
|
7730
|
+
*/
|
|
7731
|
+
function evaluatePassageName(expr, evaluate) {
|
|
7732
|
+
try {
|
|
7733
|
+
return String(evaluate(expr ?? ""));
|
|
7734
|
+
} catch {
|
|
7735
|
+
return readText(expr ?? "");
|
|
7736
|
+
}
|
|
7737
|
+
}
|
|
7738
|
+
/**
|
|
7739
|
+
* The variable a `storeVar` macro binds: its first term, as written. Custom
|
|
7740
|
+
* macros may bind one without declaring parameters.
|
|
7741
|
+
*/
|
|
7742
|
+
function readBoundVariable(rawArgs) {
|
|
7743
|
+
const src = rawArgs.trim();
|
|
7744
|
+
const first = terms(src)[0];
|
|
7745
|
+
return first ? slice(src, first) : "";
|
|
7746
|
+
}
|
|
7747
|
+
//#endregion
|
|
7623
7748
|
//#region src/components/macros/option-utils.ts
|
|
7749
|
+
/** `$var "placeholder"`, the arguments of the input macros. */
|
|
7624
7750
|
function parseVarArgs(rawArgs) {
|
|
7625
|
-
const
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7751
|
+
const { variable = "", placeholder = "" } = parseMacroArgs(rawArgs, [{
|
|
7752
|
+
name: "variable",
|
|
7753
|
+
type: "variable"
|
|
7754
|
+
}, {
|
|
7755
|
+
name: "placeholder",
|
|
7756
|
+
type: "string"
|
|
7757
|
+
}]);
|
|
7632
7758
|
return {
|
|
7633
|
-
varName,
|
|
7634
|
-
placeholder
|
|
7759
|
+
varName: variable.replace(/["']/g, ""),
|
|
7760
|
+
placeholder
|
|
7635
7761
|
};
|
|
7636
7762
|
}
|
|
7637
7763
|
/**
|
|
@@ -7662,6 +7788,7 @@ var sharedHooks = {
|
|
|
7662
7788
|
useContext
|
|
7663
7789
|
};
|
|
7664
7790
|
function defineMacro(config, source = "builtin") {
|
|
7791
|
+
const parameters = config.parameters ?? [];
|
|
7665
7792
|
function Wrapper(props) {
|
|
7666
7793
|
let className = props.className;
|
|
7667
7794
|
let id = props.id;
|
|
@@ -7672,18 +7799,13 @@ function defineMacro(config, source = "builtin") {
|
|
|
7672
7799
|
id = resolve(id);
|
|
7673
7800
|
}
|
|
7674
7801
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
7675
|
-
const
|
|
7676
|
-
const inline = useContext(InlineContext);
|
|
7677
|
-
const raw = useContext(RawTextContext);
|
|
7678
|
-
const localsValues = useContext(LocalsValuesContext);
|
|
7802
|
+
const renderOptions = useRenderOptions();
|
|
7679
7803
|
const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
|
|
7680
|
-
|
|
7681
|
-
inline,
|
|
7682
|
-
raw,
|
|
7683
|
-
locals: localsValues,
|
|
7804
|
+
...renderOptions,
|
|
7684
7805
|
...options
|
|
7685
7806
|
});
|
|
7686
7807
|
const ctx = {
|
|
7808
|
+
args: parseMacroArgs(props.rawArgs, parameters),
|
|
7687
7809
|
collectText,
|
|
7688
7810
|
sourceLocation: currentSourceLocation,
|
|
7689
7811
|
parseVarArgs,
|
|
@@ -7714,7 +7836,7 @@ function defineMacro(config, source = "builtin") {
|
|
|
7714
7836
|
ctx.evaluate = (expr) => evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
|
|
7715
7837
|
}
|
|
7716
7838
|
if (config.storeVar) {
|
|
7717
|
-
const firstToken = props.rawArgs
|
|
7839
|
+
const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, "");
|
|
7718
7840
|
if (firstToken.startsWith("%")) return h("span", { class: "error" }, `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`);
|
|
7719
7841
|
const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
|
|
7720
7842
|
const segments = varExpr.split(".");
|
|
@@ -7731,7 +7853,11 @@ function defineMacro(config, source = "builtin") {
|
|
|
7731
7853
|
return config.render(props, ctx);
|
|
7732
7854
|
}
|
|
7733
7855
|
registerMacro(config.name, Wrapper);
|
|
7734
|
-
|
|
7856
|
+
const text = config.text;
|
|
7857
|
+
registerMacroText(config.name, text && ((props, ctx) => text(props, {
|
|
7858
|
+
...ctx,
|
|
7859
|
+
args: parseMacroArgs(props.rawArgs, parameters)
|
|
7860
|
+
})));
|
|
7735
7861
|
const isBlock = config.block === true || config.block !== false && (config.subMacros?.length ?? 0) > 0;
|
|
7736
7862
|
registerMacroMetadata(config.name, {
|
|
7737
7863
|
name: config.name,
|
|
@@ -7741,7 +7867,7 @@ function defineMacro(config, source = "builtin") {
|
|
|
7741
7867
|
interpolate: config.interpolate,
|
|
7742
7868
|
merged: config.merged,
|
|
7743
7869
|
description: config.description,
|
|
7744
|
-
parameters: config.parameters,
|
|
7870
|
+
parameters: config.parameters && [...config.parameters],
|
|
7745
7871
|
source
|
|
7746
7872
|
});
|
|
7747
7873
|
if (config.subMacros) for (const sub of config.subMacros) registerSubMacro(sub);
|
|
@@ -8414,29 +8540,24 @@ defineMacro({
|
|
|
8414
8540
|
//#region src/components/macros/Set.tsx
|
|
8415
8541
|
defineMacro({
|
|
8416
8542
|
name: "set",
|
|
8543
|
+
parameters: [{
|
|
8544
|
+
name: "code",
|
|
8545
|
+
type: "expression",
|
|
8546
|
+
required: true
|
|
8547
|
+
}],
|
|
8417
8548
|
render({ rawArgs }, ctx) {
|
|
8418
|
-
|
|
8419
|
-
const failure = ctx.hooks.useRef(null);
|
|
8420
|
-
if (!ran.current) {
|
|
8421
|
-
ran.current = true;
|
|
8422
|
-
try {
|
|
8423
|
-
ctx.mutate(rawArgs);
|
|
8424
|
-
} catch (err) {
|
|
8425
|
-
failure.current = { error: err };
|
|
8426
|
-
console.error(`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`, err);
|
|
8427
|
-
}
|
|
8428
|
-
}
|
|
8429
|
-
if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
|
|
8430
|
-
macro: "set",
|
|
8431
|
-
error: failure.current.error
|
|
8432
|
-
});
|
|
8433
|
-
return null;
|
|
8549
|
+
return useRunOnce("set", rawArgs, () => ctx.mutate(rawArgs));
|
|
8434
8550
|
}
|
|
8435
8551
|
});
|
|
8436
8552
|
//#endregion
|
|
8437
8553
|
//#region src/components/macros/Print.tsx
|
|
8438
8554
|
defineMacro({
|
|
8439
8555
|
name: "print",
|
|
8556
|
+
parameters: [{
|
|
8557
|
+
name: "expression",
|
|
8558
|
+
type: "expression",
|
|
8559
|
+
required: true
|
|
8560
|
+
}],
|
|
8440
8561
|
interpolate: true,
|
|
8441
8562
|
merged: true,
|
|
8442
8563
|
render({ rawArgs }, ctx) {
|
|
@@ -8464,30 +8585,16 @@ defineMacro({
|
|
|
8464
8585
|
* applied, even `undefined`; a first evaluation that throws leaves it unset.
|
|
8465
8586
|
*/
|
|
8466
8587
|
var UNSET = Symbol("unset");
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
continue;
|
|
8478
|
-
}
|
|
8479
|
-
if (i > 0 && trimmed[i - 1] === "!") continue;
|
|
8480
|
-
const target = trimmed.slice(0, i).trim();
|
|
8481
|
-
const expr = trimmed.slice(i + 1).trim();
|
|
8482
|
-
if (!target.match(/^[$_@]\w+$/)) throw new Error(`{computed}: target must be $name, _name, or @name, got "${target}"`);
|
|
8483
|
-
checkVariableName(target.slice(1), target);
|
|
8484
|
-
return {
|
|
8485
|
-
target,
|
|
8486
|
-
expr
|
|
8487
|
-
};
|
|
8488
|
-
}
|
|
8489
|
-
}
|
|
8490
|
-
throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
|
|
8588
|
+
/** Check the arguments of "target = expression". */
|
|
8589
|
+
function assignmentArgs(rawArgs, args) {
|
|
8590
|
+
if (!args["="]) throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
|
|
8591
|
+
const { target = "", expression: expr = "" } = args;
|
|
8592
|
+
if (!target.match(/^[$_@]\w+$/)) throw new Error(`{computed}: target must be $name, _name, or @name, got "${target}"`);
|
|
8593
|
+
checkVariableName(target.slice(1), target);
|
|
8594
|
+
return {
|
|
8595
|
+
target,
|
|
8596
|
+
expr
|
|
8597
|
+
};
|
|
8491
8598
|
}
|
|
8492
8599
|
/**
|
|
8493
8600
|
* Evaluate `expr` against the current values — the store's state and the
|
|
@@ -8504,7 +8611,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
|
|
|
8504
8611
|
const { variables, temporary, transient } = readState();
|
|
8505
8612
|
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
8506
8613
|
} catch (err) {
|
|
8507
|
-
|
|
8614
|
+
logMacroError(`computed ${rawArgs}`, err);
|
|
8508
8615
|
return;
|
|
8509
8616
|
}
|
|
8510
8617
|
if (!deepEqual(prevRef.current, newValue)) {
|
|
@@ -8512,7 +8619,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
|
|
|
8512
8619
|
if (isLocal) try {
|
|
8513
8620
|
localsUpdate(name, newValue);
|
|
8514
8621
|
} catch (err) {
|
|
8515
|
-
|
|
8622
|
+
logMacroError(`computed ${rawArgs}`, err);
|
|
8516
8623
|
}
|
|
8517
8624
|
else {
|
|
8518
8625
|
const state = useStoryStore.getState();
|
|
@@ -8524,12 +8631,28 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
|
|
|
8524
8631
|
defineMacro({
|
|
8525
8632
|
name: "computed",
|
|
8526
8633
|
merged: true,
|
|
8634
|
+
parameters: [
|
|
8635
|
+
{
|
|
8636
|
+
name: "target",
|
|
8637
|
+
type: "variable",
|
|
8638
|
+
required: true
|
|
8639
|
+
},
|
|
8640
|
+
{
|
|
8641
|
+
name: "=",
|
|
8642
|
+
type: "separator"
|
|
8643
|
+
},
|
|
8644
|
+
{
|
|
8645
|
+
name: "expression",
|
|
8646
|
+
type: "expression",
|
|
8647
|
+
required: true
|
|
8648
|
+
}
|
|
8649
|
+
],
|
|
8527
8650
|
render({ rawArgs }, ctx) {
|
|
8528
8651
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged;
|
|
8529
8652
|
let target;
|
|
8530
8653
|
let expr;
|
|
8531
8654
|
try {
|
|
8532
|
-
({target, expr} =
|
|
8655
|
+
({target, expr} = assignmentArgs(rawArgs, ctx.args));
|
|
8533
8656
|
} catch (err) {
|
|
8534
8657
|
return /* @__PURE__ */ jsx(MacroError, {
|
|
8535
8658
|
macro: "computed",
|
|
@@ -8541,14 +8664,13 @@ defineMacro({
|
|
|
8541
8664
|
const name = target.slice(1);
|
|
8542
8665
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
8543
8666
|
const prevOutput = ctx.hooks.useRef(UNSET);
|
|
8667
|
+
const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
|
|
8544
8668
|
const ran = ctx.hooks.useRef(false);
|
|
8545
8669
|
if (!ran.current) {
|
|
8546
8670
|
ran.current = true;
|
|
8547
|
-
|
|
8671
|
+
compute();
|
|
8548
8672
|
}
|
|
8549
|
-
ctx.hooks.useLayoutEffect(
|
|
8550
|
-
computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
|
|
8551
|
-
}, [
|
|
8673
|
+
ctx.hooks.useLayoutEffect(compute, [
|
|
8552
8674
|
mergedVars,
|
|
8553
8675
|
mergedTemps,
|
|
8554
8676
|
mergedLocals,
|
|
@@ -8559,26 +8681,13 @@ defineMacro({
|
|
|
8559
8681
|
});
|
|
8560
8682
|
//#endregion
|
|
8561
8683
|
//#region src/components/macros/Meter.tsx
|
|
8562
|
-
/**
|
|
8563
|
-
|
|
8564
|
-
|
|
8565
|
-
* trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
|
|
8566
|
-
* `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
|
|
8567
|
-
* operand of the max expression, not the label.
|
|
8568
|
-
*/
|
|
8569
|
-
function parseMeterArgs(rawArgs) {
|
|
8570
|
-
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
8571
|
-
let labelMode = "";
|
|
8572
|
-
const label = tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(" ")) ? readWholeQuoted(tokens[tokens.length - 1]) : null;
|
|
8573
|
-
if (label !== null) {
|
|
8574
|
-
labelMode = label;
|
|
8575
|
-
tokens.pop();
|
|
8576
|
-
}
|
|
8577
|
-
if (tokens.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
|
|
8684
|
+
/** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
|
|
8685
|
+
function meterArgs(args) {
|
|
8686
|
+
if (args.current === void 0 || args.max === void 0) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
|
|
8578
8687
|
return {
|
|
8579
|
-
currentExpr:
|
|
8580
|
-
maxExpr:
|
|
8581
|
-
labelMode
|
|
8688
|
+
currentExpr: args.current,
|
|
8689
|
+
maxExpr: args.max,
|
|
8690
|
+
labelMode: args.label ?? ""
|
|
8582
8691
|
};
|
|
8583
8692
|
}
|
|
8584
8693
|
function formatLabel(current, max, labelMode) {
|
|
@@ -8591,9 +8700,25 @@ defineMacro({
|
|
|
8591
8700
|
name: "meter",
|
|
8592
8701
|
interpolate: true,
|
|
8593
8702
|
merged: true,
|
|
8594
|
-
|
|
8703
|
+
parameters: [
|
|
8704
|
+
{
|
|
8705
|
+
name: "current",
|
|
8706
|
+
type: "expression",
|
|
8707
|
+
required: true
|
|
8708
|
+
},
|
|
8709
|
+
{
|
|
8710
|
+
name: "max",
|
|
8711
|
+
type: "expression",
|
|
8712
|
+
required: true
|
|
8713
|
+
},
|
|
8714
|
+
{
|
|
8715
|
+
name: "label",
|
|
8716
|
+
type: "string"
|
|
8717
|
+
}
|
|
8718
|
+
],
|
|
8719
|
+
render(_props, ctx) {
|
|
8595
8720
|
try {
|
|
8596
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
8721
|
+
const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
|
|
8597
8722
|
const current = Number(ctx.evaluate(currentExpr));
|
|
8598
8723
|
const max = Number(ctx.evaluate(maxExpr));
|
|
8599
8724
|
const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
|
|
@@ -8698,82 +8823,46 @@ function keyOf(val, ancestors) {
|
|
|
8698
8823
|
if (Array.isArray(val)) return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(",")}]`;
|
|
8699
8824
|
if (val instanceof Map) return `Map[${[...val].map(([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`).join(",")}]`;
|
|
8700
8825
|
if (val instanceof Set) return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(",")}]`;
|
|
8701
|
-
const record = obj;
|
|
8702
|
-
const body = Object.keys(record).map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`).join(",");
|
|
8703
|
-
const className = registeredClassName(obj);
|
|
8704
|
-
return className === void 0 ? `{${body}}` : `Class(${JSON.stringify(className)}){${body}}`;
|
|
8705
|
-
} finally {
|
|
8706
|
-
ancestors.pop();
|
|
8707
|
-
}
|
|
8708
|
-
}
|
|
8709
|
-
//#endregion
|
|
8710
|
-
//#region src/components/macros/For.tsx
|
|
8711
|
-
/**
|
|
8712
|
-
*
|
|
8713
|
-
*/
|
|
8714
|
-
function
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
const
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
[
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
indexValue
|
|
8742
|
-
]);
|
|
8743
|
-
const localState = useMemo(() => createNamespace(parentValues, ownKeys, localMutations), [
|
|
8744
|
-
parentValues,
|
|
8745
|
-
ownKeys,
|
|
8746
|
-
localMutations
|
|
8747
|
-
]);
|
|
8748
|
-
const valuesRef = useRef(localState);
|
|
8749
|
-
valuesRef.current = localState;
|
|
8750
|
-
const getValues = useCallback(() => valuesRef.current, []);
|
|
8751
|
-
const update = useCallback((key, value) => {
|
|
8752
|
-
checkVariableName(key, `@${key}`);
|
|
8753
|
-
valuesRef.current = withEntry(valuesRef.current, key, value);
|
|
8754
|
-
setLocalMutations((prev) => ({
|
|
8755
|
-
...prev,
|
|
8756
|
-
[key]: value
|
|
8757
|
-
}));
|
|
8758
|
-
}, []);
|
|
8759
|
-
const updater = useMemo(() => ({
|
|
8760
|
-
update,
|
|
8761
|
-
getValues
|
|
8762
|
-
}), [update, getValues]);
|
|
8763
|
-
const nobr = useContext(NobrContext);
|
|
8764
|
-
const inline = useContext(InlineContext);
|
|
8765
|
-
const raw = useContext(RawTextContext);
|
|
8766
|
-
return /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
|
|
8767
|
-
value: updater,
|
|
8768
|
-
children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
|
|
8769
|
-
value: localState,
|
|
8770
|
-
children: renderNodes(children, {
|
|
8771
|
-
nobr,
|
|
8772
|
-
inline,
|
|
8773
|
-
raw,
|
|
8774
|
-
locals: localState
|
|
8775
|
-
})
|
|
8776
|
-
})
|
|
8826
|
+
const record = obj;
|
|
8827
|
+
const body = Object.keys(record).map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`).join(",");
|
|
8828
|
+
const className = registeredClassName(obj);
|
|
8829
|
+
return className === void 0 ? `{${body}}` : `Class(${JSON.stringify(className)}){${body}}`;
|
|
8830
|
+
} finally {
|
|
8831
|
+
ancestors.pop();
|
|
8832
|
+
}
|
|
8833
|
+
}
|
|
8834
|
+
//#endregion
|
|
8835
|
+
//#region src/components/macros/For.tsx
|
|
8836
|
+
/**
|
|
8837
|
+
* Check the arguments of "@item, @i of $list" or "@item of $list".
|
|
8838
|
+
*/
|
|
8839
|
+
function loopArgs(rawArgs, args) {
|
|
8840
|
+
if (!args.of) throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
|
|
8841
|
+
const [itemVar = "", indexVar = null] = args.variables ?? [];
|
|
8842
|
+
const listExpr = args.list ?? "";
|
|
8843
|
+
if (!itemVar.startsWith("@")) throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
|
|
8844
|
+
if (indexVar && !indexVar.startsWith("@")) throw new Error(`{for} index variable must use @ prefix: got "${indexVar}"`);
|
|
8845
|
+
checkVariableName(itemVar.slice(1), itemVar);
|
|
8846
|
+
if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
|
|
8847
|
+
return {
|
|
8848
|
+
itemVar: itemVar.slice(1),
|
|
8849
|
+
indexVar: indexVar ? indexVar.slice(1) : null,
|
|
8850
|
+
listExpr
|
|
8851
|
+
};
|
|
8852
|
+
}
|
|
8853
|
+
function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue, children }) {
|
|
8854
|
+
return /* @__PURE__ */ jsx(LocalsScope, {
|
|
8855
|
+
body: children,
|
|
8856
|
+
parentValues,
|
|
8857
|
+
ownKeys: useMemo(() => ({
|
|
8858
|
+
[itemVar]: itemValue,
|
|
8859
|
+
...indexVar ? { [indexVar]: indexValue } : void 0
|
|
8860
|
+
}), [
|
|
8861
|
+
itemVar,
|
|
8862
|
+
itemValue,
|
|
8863
|
+
indexVar,
|
|
8864
|
+
indexValue
|
|
8865
|
+
])
|
|
8777
8866
|
});
|
|
8778
8867
|
}
|
|
8779
8868
|
defineMacro({
|
|
@@ -8781,21 +8870,29 @@ defineMacro({
|
|
|
8781
8870
|
block: true,
|
|
8782
8871
|
interpolate: true,
|
|
8783
8872
|
merged: true,
|
|
8873
|
+
parameters: [
|
|
8874
|
+
{
|
|
8875
|
+
name: "variables",
|
|
8876
|
+
type: "names",
|
|
8877
|
+
required: true
|
|
8878
|
+
},
|
|
8879
|
+
{
|
|
8880
|
+
name: "of",
|
|
8881
|
+
type: "separator"
|
|
8882
|
+
},
|
|
8883
|
+
{
|
|
8884
|
+
name: "list",
|
|
8885
|
+
type: "expression",
|
|
8886
|
+
required: true
|
|
8887
|
+
}
|
|
8888
|
+
],
|
|
8784
8889
|
render({ rawArgs, children = [] }, ctx) {
|
|
8785
8890
|
const parentValues = useContext(LocalsValuesContext);
|
|
8786
|
-
let
|
|
8787
|
-
try {
|
|
8788
|
-
parsed = parseForArgs(rawArgs);
|
|
8789
|
-
} catch (err) {
|
|
8790
|
-
return /* @__PURE__ */ jsx(MacroError, {
|
|
8791
|
-
macro: "for",
|
|
8792
|
-
error: err
|
|
8793
|
-
});
|
|
8794
|
-
}
|
|
8795
|
-
const { itemVar, indexVar, listExpr } = parsed;
|
|
8891
|
+
let loop;
|
|
8796
8892
|
let list;
|
|
8797
8893
|
try {
|
|
8798
|
-
|
|
8894
|
+
loop = loopArgs(rawArgs, ctx.args);
|
|
8895
|
+
const result = ctx.evaluate(loop.listExpr);
|
|
8799
8896
|
if (!Array.isArray(result)) return /* @__PURE__ */ jsx("span", {
|
|
8800
8897
|
class: "error",
|
|
8801
8898
|
children: `{for error: expression did not evaluate to an array}`
|
|
@@ -8807,6 +8904,7 @@ defineMacro({
|
|
|
8807
8904
|
error: err
|
|
8808
8905
|
});
|
|
8809
8906
|
}
|
|
8907
|
+
const { itemVar, indexVar } = loop;
|
|
8810
8908
|
const content = list.map((item, i) => /* @__PURE__ */ jsx(ForIteration, {
|
|
8811
8909
|
parentValues,
|
|
8812
8910
|
itemVar,
|
|
@@ -8818,7 +8916,7 @@ defineMacro({
|
|
|
8818
8916
|
return ctx.wrap(content);
|
|
8819
8917
|
},
|
|
8820
8918
|
text({ rawArgs, children = [] }, ctx) {
|
|
8821
|
-
const { itemVar, indexVar, listExpr } =
|
|
8919
|
+
const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
|
|
8822
8920
|
const list = ctx.evaluate(listExpr);
|
|
8823
8921
|
if (!Array.isArray(list)) throw new Error("expression did not evaluate to an array");
|
|
8824
8922
|
return list.map((item, i) => ctx.renderText(children, {
|
|
@@ -8838,7 +8936,7 @@ defineMacro({
|
|
|
8838
8936
|
try {
|
|
8839
8937
|
ctx.mutate(code);
|
|
8840
8938
|
} catch (err) {
|
|
8841
|
-
|
|
8939
|
+
logMacroError("do", err);
|
|
8842
8940
|
}
|
|
8843
8941
|
}, []);
|
|
8844
8942
|
return null;
|
|
@@ -8857,7 +8955,7 @@ defineMacro({
|
|
|
8857
8955
|
function liveLocalsView(getValues) {
|
|
8858
8956
|
return new Proxy(createNamespace(), {
|
|
8859
8957
|
get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
|
|
8860
|
-
has: (_, key) => typeof key === "string" && hasOwn
|
|
8958
|
+
has: (_, key) => typeof key === "string" && hasOwn(getValues(), key),
|
|
8861
8959
|
ownKeys: () => Reflect.ownKeys(getValues()),
|
|
8862
8960
|
getOwnPropertyDescriptor: (_, key) => {
|
|
8863
8961
|
const desc = Object.getOwnPropertyDescriptor(getValues(), key);
|
|
@@ -8869,27 +8967,20 @@ function liveLocalsView(getValues) {
|
|
|
8869
8967
|
});
|
|
8870
8968
|
}
|
|
8871
8969
|
//#endregion
|
|
8872
|
-
//#region src/utils/parse-delay.ts
|
|
8873
|
-
/**
|
|
8874
|
-
* Parse a delay string into milliseconds.
|
|
8875
|
-
* Supports: "2s" → 2000, "500ms" → 500, bare number → ms
|
|
8876
|
-
*/
|
|
8877
|
-
function parseDelay(str) {
|
|
8878
|
-
const trimmed = str.trim();
|
|
8879
|
-
if (trimmed.endsWith("ms")) return parseFloat(trimmed.slice(0, -2));
|
|
8880
|
-
if (trimmed.endsWith("s")) return parseFloat(trimmed.slice(0, -1)) * 1e3;
|
|
8881
|
-
return parseFloat(trimmed);
|
|
8882
|
-
}
|
|
8883
|
-
//#endregion
|
|
8884
8970
|
//#region src/components/macros/Repeat.tsx
|
|
8885
8971
|
var RepeatContext = createContext({ stop: () => {} });
|
|
8886
8972
|
defineMacro({
|
|
8887
8973
|
name: "repeat",
|
|
8888
8974
|
block: true,
|
|
8889
8975
|
interpolate: true,
|
|
8890
|
-
|
|
8976
|
+
parameters: [{
|
|
8977
|
+
name: "delay",
|
|
8978
|
+
type: "delay",
|
|
8979
|
+
required: true
|
|
8980
|
+
}],
|
|
8981
|
+
render({ children = [] }, ctx) {
|
|
8891
8982
|
const { useState, useEffect, useCallback } = ctx.hooks;
|
|
8892
|
-
const delay =
|
|
8983
|
+
const delay = ctx.args.delay ?? 0;
|
|
8893
8984
|
const [count, setCount] = useState(0);
|
|
8894
8985
|
const [stopped, setStopped] = useState(false);
|
|
8895
8986
|
const stop = useCallback(() => setStopped(true), []);
|
|
@@ -8936,8 +9027,7 @@ defineMacro({
|
|
|
8936
9027
|
*/
|
|
8937
9028
|
function useDetachedBody() {
|
|
8938
9029
|
const updater = useContext(LocalsUpdateContext);
|
|
8939
|
-
const nobr =
|
|
8940
|
-
const inline = useContext(InlineContext);
|
|
9030
|
+
const { nobr, inline } = useRenderOptions();
|
|
8941
9031
|
const widgetChildren = useContext(WidgetChildrenContext);
|
|
8942
9032
|
const repeat = useContext(RepeatContext);
|
|
8943
9033
|
const closeDialog = useContext(DialogCloseContext);
|
|
@@ -8980,9 +9070,13 @@ defineMacro({
|
|
|
8980
9070
|
name: "button",
|
|
8981
9071
|
block: true,
|
|
8982
9072
|
interpolate: true,
|
|
9073
|
+
parameters: [{
|
|
9074
|
+
name: "label",
|
|
9075
|
+
type: "text",
|
|
9076
|
+
required: true
|
|
9077
|
+
}],
|
|
8983
9078
|
render({ rawArgs, children = [] }, ctx) {
|
|
8984
|
-
const
|
|
8985
|
-
const label = ctx.resolve?.(text) ?? rawArgs;
|
|
9079
|
+
const label = ctx.resolve?.(ctx.args.label ?? "") ?? rawArgs;
|
|
8986
9080
|
const runBody = useDetachedBody();
|
|
8987
9081
|
const handleClick = () => runBody(children);
|
|
8988
9082
|
ctx.useAction({
|
|
@@ -9002,30 +9096,22 @@ defineMacro({
|
|
|
9002
9096
|
});
|
|
9003
9097
|
//#endregion
|
|
9004
9098
|
//#region src/components/macros/MacroLink.tsx
|
|
9005
|
-
function parseLinkArgs(rawArgs) {
|
|
9006
|
-
const parts = [];
|
|
9007
|
-
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
9008
|
-
let m;
|
|
9009
|
-
while ((m = re.exec(rawArgs)) !== null) parts.push(unescapeQuoted(m[2]));
|
|
9010
|
-
if (parts.length >= 2) return {
|
|
9011
|
-
display: parts[0],
|
|
9012
|
-
passage: parts[1]
|
|
9013
|
-
};
|
|
9014
|
-
if (parts.length === 1) return {
|
|
9015
|
-
display: parts[0],
|
|
9016
|
-
passage: null
|
|
9017
|
-
};
|
|
9018
|
-
return {
|
|
9019
|
-
display: rawArgs.trim(),
|
|
9020
|
-
passage: null
|
|
9021
|
-
};
|
|
9022
|
-
}
|
|
9023
9099
|
defineMacro({
|
|
9024
9100
|
name: "link",
|
|
9025
9101
|
block: true,
|
|
9026
9102
|
interpolate: true,
|
|
9103
|
+
parameters: [{
|
|
9104
|
+
name: "text",
|
|
9105
|
+
type: "string",
|
|
9106
|
+
required: true
|
|
9107
|
+
}, {
|
|
9108
|
+
name: "passage",
|
|
9109
|
+
type: "string"
|
|
9110
|
+
}],
|
|
9027
9111
|
render({ rawArgs, children = [] }, ctx) {
|
|
9028
|
-
const {
|
|
9112
|
+
const { text } = ctx.args;
|
|
9113
|
+
const display = text ?? rawArgs.trim();
|
|
9114
|
+
const passage = text === void 0 ? null : ctx.args.passage ?? null;
|
|
9029
9115
|
const runBody = useDetachedBody();
|
|
9030
9116
|
const perform = () => {
|
|
9031
9117
|
runBody(children);
|
|
@@ -9054,9 +9140,27 @@ defineMacro({
|
|
|
9054
9140
|
});
|
|
9055
9141
|
//#endregion
|
|
9056
9142
|
//#region src/components/macros/Switch.tsx
|
|
9143
|
+
/**
|
|
9144
|
+
* The first {case} branch whose value equals `value`, else the {default}
|
|
9145
|
+
* branch, else null. Throws what a case expression throws.
|
|
9146
|
+
*/
|
|
9147
|
+
function selectBranch(value, branches, evaluate) {
|
|
9148
|
+
let defaultBranch = null;
|
|
9149
|
+
for (let i = 1; i < branches.length; i++) {
|
|
9150
|
+
const branch = branches[i];
|
|
9151
|
+
if (branch.rawArgs === "") defaultBranch = branch;
|
|
9152
|
+
else if (value === evaluate(branch.rawArgs)) return branch;
|
|
9153
|
+
}
|
|
9154
|
+
return defaultBranch;
|
|
9155
|
+
}
|
|
9057
9156
|
defineMacro({
|
|
9058
9157
|
name: "switch",
|
|
9059
9158
|
subMacros: ["case", "default"],
|
|
9159
|
+
parameters: [{
|
|
9160
|
+
name: "expression",
|
|
9161
|
+
type: "expression",
|
|
9162
|
+
required: true
|
|
9163
|
+
}],
|
|
9060
9164
|
merged: true,
|
|
9061
9165
|
render({ rawArgs, branches = [] }, ctx) {
|
|
9062
9166
|
let switchValue;
|
|
@@ -9068,35 +9172,20 @@ defineMacro({
|
|
|
9068
9172
|
error: err
|
|
9069
9173
|
});
|
|
9070
9174
|
}
|
|
9071
|
-
let
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
const caseValue = ctx.evaluate(branch.rawArgs);
|
|
9080
|
-
if (switchValue === caseValue) return /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
|
|
9081
|
-
} catch (err) {
|
|
9082
|
-
return /* @__PURE__ */ jsx(MacroError, {
|
|
9083
|
-
macro: "case",
|
|
9084
|
-
error: err
|
|
9085
|
-
});
|
|
9086
|
-
}
|
|
9175
|
+
let branch;
|
|
9176
|
+
try {
|
|
9177
|
+
branch = selectBranch(switchValue, branches, ctx.evaluate);
|
|
9178
|
+
} catch (err) {
|
|
9179
|
+
return /* @__PURE__ */ jsx(MacroError, {
|
|
9180
|
+
macro: "case",
|
|
9181
|
+
error: err
|
|
9182
|
+
});
|
|
9087
9183
|
}
|
|
9088
|
-
|
|
9089
|
-
return null;
|
|
9184
|
+
return branch && /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
|
|
9090
9185
|
},
|
|
9091
9186
|
text({ rawArgs, branches = [] }, ctx) {
|
|
9092
|
-
const
|
|
9093
|
-
|
|
9094
|
-
for (let i = 1; i < branches.length; i++) {
|
|
9095
|
-
const branch = branches[i];
|
|
9096
|
-
if (branch.rawArgs === "") defaultBranch = branch;
|
|
9097
|
-
else if (switchValue === ctx.evaluate(branch.rawArgs)) return ctx.renderText(branch.children);
|
|
9098
|
-
}
|
|
9099
|
-
return defaultBranch ? ctx.renderText(defaultBranch.children) : "";
|
|
9187
|
+
const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
|
|
9188
|
+
return branch ? ctx.renderText(branch.children) : "";
|
|
9100
9189
|
}
|
|
9101
9190
|
});
|
|
9102
9191
|
//#endregion
|
|
@@ -9156,9 +9245,14 @@ defineMacro({
|
|
|
9156
9245
|
name: "type",
|
|
9157
9246
|
block: true,
|
|
9158
9247
|
interpolate: true,
|
|
9159
|
-
|
|
9248
|
+
parameters: [{
|
|
9249
|
+
name: "delay",
|
|
9250
|
+
type: "delay",
|
|
9251
|
+
required: true
|
|
9252
|
+
}],
|
|
9253
|
+
render({ children = [] }, ctx) {
|
|
9160
9254
|
const { useState, useEffect, useRef } = ctx.hooks;
|
|
9161
|
-
const speed =
|
|
9255
|
+
const speed = ctx.args.delay ?? 0;
|
|
9162
9256
|
const containerRef = useRef(null);
|
|
9163
9257
|
const [totalChars, setTotalChars] = useState(0);
|
|
9164
9258
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
@@ -9339,11 +9433,18 @@ defineMacro({
|
|
|
9339
9433
|
name: "dialog",
|
|
9340
9434
|
block: true,
|
|
9341
9435
|
interpolate: true,
|
|
9342
|
-
|
|
9436
|
+
parameters: [{
|
|
9437
|
+
name: "label",
|
|
9438
|
+
type: "text",
|
|
9439
|
+
required: true
|
|
9440
|
+
}, {
|
|
9441
|
+
name: "noclose",
|
|
9442
|
+
type: "flag"
|
|
9443
|
+
}],
|
|
9444
|
+
render({ children = [] }, ctx) {
|
|
9343
9445
|
const [open, setOpen] = ctx.hooks.useState(false);
|
|
9344
|
-
const noclose =
|
|
9345
|
-
const
|
|
9346
|
-
const label = ctx.resolve?.(readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
|
|
9446
|
+
const { noclose } = ctx.args;
|
|
9447
|
+
const label = ctx.resolve(ctx.args.label ?? "");
|
|
9347
9448
|
const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
|
|
9348
9449
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
9349
9450
|
id: ctx.id,
|
|
@@ -9750,59 +9851,22 @@ defineMacro({
|
|
|
9750
9851
|
});
|
|
9751
9852
|
//#endregion
|
|
9752
9853
|
//#region src/components/macros/Include.tsx
|
|
9753
|
-
var FLAG = "inline";
|
|
9754
|
-
/**
|
|
9755
|
-
* Split a standalone `inline` flag off the start or end of the include
|
|
9756
|
-
* arguments. The flag only counts outside quotes, brackets, parentheses and
|
|
9757
|
-
* braces, separated by whitespace from the target expression, so passage
|
|
9758
|
-
* names and expressions containing the word stay intact (#201).
|
|
9759
|
-
*/
|
|
9760
|
-
function parseIncludeArgs(rawArgs) {
|
|
9761
|
-
const trimmed = rawArgs.trim();
|
|
9762
|
-
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
9763
|
-
let first = null;
|
|
9764
|
-
let last = null;
|
|
9765
|
-
if (spaces.length > 0) {
|
|
9766
|
-
let s = 0;
|
|
9767
|
-
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s] + 1) s++;
|
|
9768
|
-
first = [spaces[0], spaces[s] + 1];
|
|
9769
|
-
let e = spaces.length - 1;
|
|
9770
|
-
while (e > 0 && spaces[e - 1] === spaces[e] - 1) e--;
|
|
9771
|
-
last = [spaces[e], spaces[spaces.length - 1] + 1];
|
|
9772
|
-
}
|
|
9773
|
-
if (last && last[1] === trimmed.length - 6 && trimmed.endsWith(FLAG)) {
|
|
9774
|
-
const rest = trimmed.slice(0, last[0]);
|
|
9775
|
-
if (!endsWithOperator(rest)) return {
|
|
9776
|
-
nameExpr: rest,
|
|
9777
|
-
inline: true
|
|
9778
|
-
};
|
|
9779
|
-
}
|
|
9780
|
-
if (first && first[0] === 6 && trimmed.startsWith(FLAG)) {
|
|
9781
|
-
const rest = trimmed.slice(first[1]);
|
|
9782
|
-
if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest)) return {
|
|
9783
|
-
nameExpr: rest,
|
|
9784
|
-
inline: true
|
|
9785
|
-
};
|
|
9786
|
-
}
|
|
9787
|
-
return {
|
|
9788
|
-
nameExpr: trimmed,
|
|
9789
|
-
inline: false
|
|
9790
|
-
};
|
|
9791
|
-
}
|
|
9792
9854
|
defineMacro({
|
|
9793
9855
|
name: "include",
|
|
9794
9856
|
interpolate: true,
|
|
9795
9857
|
merged: true,
|
|
9796
|
-
|
|
9858
|
+
parameters: [{
|
|
9859
|
+
name: "inline",
|
|
9860
|
+
type: "flag"
|
|
9861
|
+
}, {
|
|
9862
|
+
name: "passage",
|
|
9863
|
+
type: "expression",
|
|
9864
|
+
required: true
|
|
9865
|
+
}],
|
|
9866
|
+
render(_props, ctx) {
|
|
9797
9867
|
const storyData = useStoryStore((s) => s.storyData);
|
|
9798
|
-
const {
|
|
9799
|
-
|
|
9800
|
-
try {
|
|
9801
|
-
const result = ctx.evaluate(nameExpr);
|
|
9802
|
-
passageName = String(result);
|
|
9803
|
-
} catch {
|
|
9804
|
-
passageName = nameExpr.replace(/^["']|["']$/g, "");
|
|
9805
|
-
}
|
|
9868
|
+
const { inline } = ctx.args;
|
|
9869
|
+
const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate);
|
|
9806
9870
|
const passage = storyData?.passages.get(passageName);
|
|
9807
9871
|
const ast = ctx.hooks.useMemo(() => passage ? buildAST(tokenize(passage.content)) : null, [passage]);
|
|
9808
9872
|
if (!storyData) return null;
|
|
@@ -9832,16 +9896,14 @@ defineMacro({
|
|
|
9832
9896
|
defineMacro({
|
|
9833
9897
|
name: "goto",
|
|
9834
9898
|
merged: true,
|
|
9835
|
-
|
|
9899
|
+
parameters: [{
|
|
9900
|
+
name: "passage",
|
|
9901
|
+
type: "expression",
|
|
9902
|
+
required: true
|
|
9903
|
+
}],
|
|
9904
|
+
render(_props, ctx) {
|
|
9836
9905
|
ctx.hooks.useLayoutEffect(() => {
|
|
9837
|
-
|
|
9838
|
-
try {
|
|
9839
|
-
const result = ctx.evaluate(rawArgs);
|
|
9840
|
-
passageName = String(result);
|
|
9841
|
-
} catch {
|
|
9842
|
-
passageName = rawArgs.replace(/^["']|["']$/g, "");
|
|
9843
|
-
}
|
|
9844
|
-
useStoryStore.getState().navigate(passageName);
|
|
9906
|
+
useStoryStore.getState().navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate));
|
|
9845
9907
|
}, []);
|
|
9846
9908
|
return null;
|
|
9847
9909
|
}
|
|
@@ -9850,124 +9912,90 @@ defineMacro({
|
|
|
9850
9912
|
//#region src/components/macros/Unset.tsx
|
|
9851
9913
|
defineMacro({
|
|
9852
9914
|
name: "unset",
|
|
9915
|
+
parameters: [{
|
|
9916
|
+
name: "variable",
|
|
9917
|
+
type: "variable",
|
|
9918
|
+
required: true
|
|
9919
|
+
}],
|
|
9853
9920
|
render({ rawArgs }, ctx) {
|
|
9854
|
-
|
|
9855
|
-
const failure = ctx.hooks.useRef(null);
|
|
9856
|
-
if (!ran.current) {
|
|
9857
|
-
ran.current = true;
|
|
9921
|
+
return useRunOnce("unset", rawArgs, () => {
|
|
9858
9922
|
const state = useStoryStore.getState();
|
|
9859
|
-
const name =
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
|
|
9866
|
-
} catch (err) {
|
|
9867
|
-
failure.current = { error: err };
|
|
9868
|
-
console.error(`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`, err);
|
|
9869
|
-
}
|
|
9870
|
-
}
|
|
9871
|
-
if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
|
|
9872
|
-
macro: "unset",
|
|
9873
|
-
error: failure.current.error
|
|
9923
|
+
const name = ctx.args.variable ?? "";
|
|
9924
|
+
if (name.startsWith("$")) state.deleteVariable(name.slice(1));
|
|
9925
|
+
else if (name.startsWith("_")) state.deleteTemporary(name.slice(1));
|
|
9926
|
+
else if (name.startsWith("%")) state.deleteTransient(name.slice(1));
|
|
9927
|
+
else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
|
|
9928
|
+
else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
|
|
9874
9929
|
});
|
|
9875
|
-
return null;
|
|
9876
9930
|
}
|
|
9877
9931
|
});
|
|
9878
9932
|
//#endregion
|
|
9933
|
+
//#region src/components/macros/input-macro.ts
|
|
9934
|
+
/**
|
|
9935
|
+
* Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
|
|
9936
|
+
* `type` (or, without one, a `<textarea>`) bound to the story variable.
|
|
9937
|
+
* `toValue` turns what the reader types, or an automation action's value
|
|
9938
|
+
* (`undefined` when it has none), into the variable's value: by default,
|
|
9939
|
+
* the text.
|
|
9940
|
+
*/
|
|
9941
|
+
function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? String(input) : "") {
|
|
9942
|
+
defineMacro({
|
|
9943
|
+
name,
|
|
9944
|
+
storeVar: true,
|
|
9945
|
+
parameters: [{
|
|
9946
|
+
name: "variable",
|
|
9947
|
+
type: "variable",
|
|
9948
|
+
required: true
|
|
9949
|
+
}, {
|
|
9950
|
+
name: "placeholder",
|
|
9951
|
+
type: "string"
|
|
9952
|
+
}],
|
|
9953
|
+
render(_props, ctx) {
|
|
9954
|
+
const placeholder = ctx.args.placeholder ?? "";
|
|
9955
|
+
ctx.useAction({
|
|
9956
|
+
type: name,
|
|
9957
|
+
key: `$${ctx.varName}`,
|
|
9958
|
+
authorId: ctx.id,
|
|
9959
|
+
label: placeholder || ctx.varName,
|
|
9960
|
+
variable: ctx.varName,
|
|
9961
|
+
value: ctx.value,
|
|
9962
|
+
perform: (v) => ctx.setValue(toValue(v))
|
|
9963
|
+
});
|
|
9964
|
+
return h(type ? "input" : "textarea", {
|
|
9965
|
+
type,
|
|
9966
|
+
id: ctx.id,
|
|
9967
|
+
class: ctx.cls,
|
|
9968
|
+
value: ctx.value == null ? "" : String(ctx.value),
|
|
9969
|
+
placeholder,
|
|
9970
|
+
onInput: (e) => ctx.setValue(toValue(e.target.value))
|
|
9971
|
+
});
|
|
9972
|
+
}
|
|
9973
|
+
});
|
|
9974
|
+
}
|
|
9975
|
+
//#endregion
|
|
9879
9976
|
//#region src/components/macros/Textbox.tsx
|
|
9880
|
-
|
|
9881
|
-
name: "textbox",
|
|
9882
|
-
storeVar: true,
|
|
9883
|
-
render({ rawArgs }, ctx) {
|
|
9884
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
9885
|
-
ctx.useAction({
|
|
9886
|
-
type: "textbox",
|
|
9887
|
-
key: `$${ctx.varName}`,
|
|
9888
|
-
authorId: ctx.id,
|
|
9889
|
-
label: placeholder || ctx.varName,
|
|
9890
|
-
variable: ctx.varName,
|
|
9891
|
-
value: ctx.value,
|
|
9892
|
-
perform: (v) => ctx.setValue(v !== void 0 ? String(v) : "")
|
|
9893
|
-
});
|
|
9894
|
-
return /* @__PURE__ */ jsx("input", {
|
|
9895
|
-
type: "text",
|
|
9896
|
-
id: ctx.id,
|
|
9897
|
-
class: ctx.cls,
|
|
9898
|
-
value: ctx.value == null ? "" : String(ctx.value),
|
|
9899
|
-
placeholder,
|
|
9900
|
-
onInput: (e) => ctx.setValue(e.target.value)
|
|
9901
|
-
});
|
|
9902
|
-
}
|
|
9903
|
-
});
|
|
9977
|
+
defineInputMacro("textbox", "text");
|
|
9904
9978
|
//#endregion
|
|
9905
9979
|
//#region src/components/macros/Numberbox.tsx
|
|
9906
|
-
|
|
9907
|
-
name: "numberbox",
|
|
9908
|
-
storeVar: true,
|
|
9909
|
-
render({ rawArgs }, ctx) {
|
|
9910
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
9911
|
-
ctx.useAction({
|
|
9912
|
-
type: "numberbox",
|
|
9913
|
-
key: `$${ctx.varName}`,
|
|
9914
|
-
authorId: ctx.id,
|
|
9915
|
-
label: placeholder || ctx.varName,
|
|
9916
|
-
variable: ctx.varName,
|
|
9917
|
-
value: ctx.value,
|
|
9918
|
-
perform: (v) => ctx.setValue(v !== void 0 ? Number(v) : 0)
|
|
9919
|
-
});
|
|
9920
|
-
return /* @__PURE__ */ jsx("input", {
|
|
9921
|
-
type: "number",
|
|
9922
|
-
id: ctx.id,
|
|
9923
|
-
class: ctx.cls,
|
|
9924
|
-
value: ctx.value == null ? "" : String(ctx.value),
|
|
9925
|
-
placeholder,
|
|
9926
|
-
onInput: (e) => {
|
|
9927
|
-
const val = e.target.value;
|
|
9928
|
-
ctx.setValue(val === "" ? 0 : Number(val));
|
|
9929
|
-
}
|
|
9930
|
-
});
|
|
9931
|
-
}
|
|
9932
|
-
});
|
|
9980
|
+
defineInputMacro("numberbox", "number", (v) => v !== void 0 ? Number(v) : 0);
|
|
9933
9981
|
//#endregion
|
|
9934
9982
|
//#region src/components/macros/Textarea.tsx
|
|
9935
|
-
|
|
9936
|
-
name: "textarea",
|
|
9937
|
-
storeVar: true,
|
|
9938
|
-
render({ rawArgs }, ctx) {
|
|
9939
|
-
const { placeholder } = ctx.parseVarArgs(rawArgs);
|
|
9940
|
-
ctx.useAction({
|
|
9941
|
-
type: "textarea",
|
|
9942
|
-
key: `$${ctx.varName}`,
|
|
9943
|
-
authorId: ctx.id,
|
|
9944
|
-
label: placeholder || ctx.varName,
|
|
9945
|
-
variable: ctx.varName,
|
|
9946
|
-
value: ctx.value,
|
|
9947
|
-
perform: (v) => ctx.setValue(v !== void 0 ? String(v) : "")
|
|
9948
|
-
});
|
|
9949
|
-
return /* @__PURE__ */ jsx("textarea", {
|
|
9950
|
-
id: ctx.id,
|
|
9951
|
-
class: ctx.cls,
|
|
9952
|
-
value: ctx.value == null ? "" : String(ctx.value),
|
|
9953
|
-
placeholder,
|
|
9954
|
-
onInput: (e) => ctx.setValue(e.target.value)
|
|
9955
|
-
});
|
|
9956
|
-
}
|
|
9957
|
-
});
|
|
9983
|
+
defineInputMacro("textarea");
|
|
9958
9984
|
//#endregion
|
|
9959
9985
|
//#region src/components/macros/Checkbox.tsx
|
|
9960
|
-
function parseCheckboxLabel(rawArgs) {
|
|
9961
|
-
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+)$/s);
|
|
9962
|
-
if (!match) return "";
|
|
9963
|
-
const rest = match[1].trimEnd() || match[1];
|
|
9964
|
-
return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
|
|
9965
|
-
}
|
|
9966
9986
|
defineMacro({
|
|
9967
9987
|
name: "checkbox",
|
|
9968
9988
|
storeVar: true,
|
|
9969
|
-
|
|
9970
|
-
|
|
9989
|
+
parameters: [{
|
|
9990
|
+
name: "variable",
|
|
9991
|
+
type: "variable",
|
|
9992
|
+
required: true
|
|
9993
|
+
}, {
|
|
9994
|
+
name: "label",
|
|
9995
|
+
type: "text"
|
|
9996
|
+
}],
|
|
9997
|
+
render(_props, ctx) {
|
|
9998
|
+
const label = ctx.args.label ?? "";
|
|
9971
9999
|
ctx.useAction({
|
|
9972
10000
|
type: "checkbox",
|
|
9973
10001
|
key: `$${ctx.varName}`,
|
|
@@ -9990,38 +10018,28 @@ defineMacro({
|
|
|
9990
10018
|
});
|
|
9991
10019
|
//#endregion
|
|
9992
10020
|
//#region src/components/macros/Radiobutton.tsx
|
|
9993
|
-
function parseRadioArgs(rawArgs) {
|
|
9994
|
-
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
9995
|
-
if (head) {
|
|
9996
|
-
const rest = rawArgs.slice(head[0].length).trim();
|
|
9997
|
-
const value = readQuoted(rest, 0);
|
|
9998
|
-
if (value && (value.end === rest.length || isWhitespace(rest[value.end]))) {
|
|
9999
|
-
const labelRaw = rest.slice(value.end).trim();
|
|
10000
|
-
const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
|
|
10001
|
-
return {
|
|
10002
|
-
value: value.value,
|
|
10003
|
-
label
|
|
10004
|
-
};
|
|
10005
|
-
}
|
|
10006
|
-
}
|
|
10007
|
-
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s);
|
|
10008
|
-
if (!match) {
|
|
10009
|
-
const parts = rawArgs.trim().split(/\s+/).slice(1);
|
|
10010
|
-
return {
|
|
10011
|
-
value: parts[0] ?? "",
|
|
10012
|
-
label: parts.slice(1).join(" ")
|
|
10013
|
-
};
|
|
10014
|
-
}
|
|
10015
|
-
return {
|
|
10016
|
-
value: match[1],
|
|
10017
|
-
label: match[2]
|
|
10018
|
-
};
|
|
10019
|
-
}
|
|
10020
10021
|
defineMacro({
|
|
10021
10022
|
name: "radiobutton",
|
|
10022
10023
|
storeVar: true,
|
|
10023
|
-
|
|
10024
|
-
|
|
10024
|
+
parameters: [
|
|
10025
|
+
{
|
|
10026
|
+
name: "variable",
|
|
10027
|
+
type: "variable",
|
|
10028
|
+
required: true
|
|
10029
|
+
},
|
|
10030
|
+
{
|
|
10031
|
+
name: "value",
|
|
10032
|
+
type: "text",
|
|
10033
|
+
required: true
|
|
10034
|
+
},
|
|
10035
|
+
{
|
|
10036
|
+
name: "label",
|
|
10037
|
+
type: "text"
|
|
10038
|
+
}
|
|
10039
|
+
],
|
|
10040
|
+
render(_props, ctx) {
|
|
10041
|
+
const radioValue = ctx.args.value ?? "";
|
|
10042
|
+
const label = ctx.args.label ?? "";
|
|
10025
10043
|
ctx.useAction({
|
|
10026
10044
|
type: "radiobutton",
|
|
10027
10045
|
key: `$${ctx.varName}:${radioValue}`,
|
|
@@ -10049,6 +10067,11 @@ defineMacro({
|
|
|
10049
10067
|
name: "listbox",
|
|
10050
10068
|
subMacros: ["option"],
|
|
10051
10069
|
storeVar: true,
|
|
10070
|
+
parameters: [{
|
|
10071
|
+
name: "variable",
|
|
10072
|
+
type: "variable",
|
|
10073
|
+
required: true
|
|
10074
|
+
}],
|
|
10052
10075
|
render({ children = [] }, ctx) {
|
|
10053
10076
|
const options = ctx.extractOptions(children);
|
|
10054
10077
|
ctx.useAction({
|
|
@@ -10081,6 +10104,11 @@ defineMacro({
|
|
|
10081
10104
|
name: "cycle",
|
|
10082
10105
|
block: true,
|
|
10083
10106
|
storeVar: true,
|
|
10107
|
+
parameters: [{
|
|
10108
|
+
name: "variable",
|
|
10109
|
+
type: "variable",
|
|
10110
|
+
required: true
|
|
10111
|
+
}],
|
|
10084
10112
|
render({ children = [] }, ctx) {
|
|
10085
10113
|
const options = ctx.extractOptions(children);
|
|
10086
10114
|
const handleClick = () => {
|
|
@@ -10317,6 +10345,16 @@ function setCSSProperties(el, config) {
|
|
|
10317
10345
|
el.style.setProperty("--passage-pause", `${config.pause / 1e3}s`);
|
|
10318
10346
|
}
|
|
10319
10347
|
/**
|
|
10348
|
+
* Set up `containerEl` for an outgoing transition and snapshot the passage
|
|
10349
|
+
* it shows, or return null if there is no container or passage.
|
|
10350
|
+
*/
|
|
10351
|
+
function snapshotPassage(containerEl, config) {
|
|
10352
|
+
if (!containerEl) return null;
|
|
10353
|
+
setCSSProperties(containerEl, config);
|
|
10354
|
+
const passageEl = containerEl.querySelector(".passage");
|
|
10355
|
+
return passageEl ? createSnapshot(passageEl, config.type) : null;
|
|
10356
|
+
}
|
|
10357
|
+
/**
|
|
10320
10358
|
* Remove all snapshot elements and crossfading class from a container.
|
|
10321
10359
|
*/
|
|
10322
10360
|
function cleanupSnapshots(containerEl) {
|
|
@@ -10384,18 +10422,12 @@ defineMacro({
|
|
|
10384
10422
|
setDisplayedPassage(currentPassage);
|
|
10385
10423
|
return;
|
|
10386
10424
|
}
|
|
10425
|
+
const snapshot = config.type === "fade-through" || config.type === "crossfade" ? snapshotPassage(containerEl, config) : null;
|
|
10426
|
+
if (!containerEl || !snapshot) {
|
|
10427
|
+
setDisplayedPassage(currentPassage);
|
|
10428
|
+
return;
|
|
10429
|
+
}
|
|
10387
10430
|
if (config.type === "fade-through") {
|
|
10388
|
-
if (!containerEl) {
|
|
10389
|
-
setDisplayedPassage(currentPassage);
|
|
10390
|
-
return;
|
|
10391
|
-
}
|
|
10392
|
-
setCSSProperties(containerEl, config);
|
|
10393
|
-
const passageEl = containerEl.querySelector(".passage");
|
|
10394
|
-
if (!passageEl) {
|
|
10395
|
-
setDisplayedPassage(currentPassage);
|
|
10396
|
-
return;
|
|
10397
|
-
}
|
|
10398
|
-
const snapshot = createSnapshot(passageEl, config.type);
|
|
10399
10431
|
containerEl.appendChild(snapshot);
|
|
10400
10432
|
setDisplayedPassage("");
|
|
10401
10433
|
const incomingDelay = config.duration + config.pause;
|
|
@@ -10408,27 +10440,12 @@ defineMacro({
|
|
|
10408
10440
|
}, cleanupDelay)];
|
|
10409
10441
|
return;
|
|
10410
10442
|
}
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
setDisplayedPassage(currentPassage);
|
|
10414
|
-
return;
|
|
10415
|
-
}
|
|
10416
|
-
setCSSProperties(containerEl, config);
|
|
10417
|
-
const passageEl = containerEl.querySelector(".passage");
|
|
10418
|
-
if (!passageEl) {
|
|
10419
|
-
setDisplayedPassage(currentPassage);
|
|
10420
|
-
return;
|
|
10421
|
-
}
|
|
10422
|
-
const snapshot = createSnapshot(passageEl, config.type);
|
|
10423
|
-
containerEl.classList.add("passage-container--crossfading");
|
|
10424
|
-
containerEl.appendChild(snapshot);
|
|
10425
|
-
setDisplayedPassage(currentPassage);
|
|
10426
|
-
timeoutsRef.current = [setTimeout(() => {
|
|
10427
|
-
cleanupSnapshots(containerEl);
|
|
10428
|
-
}, config.duration)];
|
|
10429
|
-
return;
|
|
10430
|
-
}
|
|
10443
|
+
containerEl.classList.add("passage-container--crossfading");
|
|
10444
|
+
containerEl.appendChild(snapshot);
|
|
10431
10445
|
setDisplayedPassage(currentPassage);
|
|
10446
|
+
timeoutsRef.current = [setTimeout(() => {
|
|
10447
|
+
cleanupSnapshots(containerEl);
|
|
10448
|
+
}, config.duration)];
|
|
10432
10449
|
}, [navigationId]);
|
|
10433
10450
|
const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
|
|
10434
10451
|
if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
|
|
@@ -10460,87 +10477,46 @@ defineMacro({
|
|
|
10460
10477
|
});
|
|
10461
10478
|
//#endregion
|
|
10462
10479
|
//#region src/components/macros/Watch.tsx
|
|
10463
|
-
var WORD_RE = /\w+/y;
|
|
10464
|
-
var DIGITS_RE = /\d+/y;
|
|
10465
|
-
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
10466
|
-
function matchAt(re, src, pos) {
|
|
10467
|
-
re.lastIndex = pos;
|
|
10468
|
-
return re.exec(src)?.[0] ?? null;
|
|
10469
|
-
}
|
|
10470
|
-
/**
|
|
10471
|
-
* Parse `{watch "condition" keyword value …}`: a quoted condition followed
|
|
10472
|
-
* by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
|
|
10473
|
-
* `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
10474
|
-
* Returns `null` when the arguments do not start with a quoted condition.
|
|
10475
|
-
*/
|
|
10476
|
-
function parseWatchArgs(rawArgs) {
|
|
10477
|
-
const raw = rawArgs.trim();
|
|
10478
|
-
const cond = readQuoted(raw, 0);
|
|
10479
|
-
if (!cond) return null;
|
|
10480
|
-
const options = {};
|
|
10481
|
-
let i = cond.end;
|
|
10482
|
-
while (i < raw.length) {
|
|
10483
|
-
const key = matchAt(WORD_RE, raw, i);
|
|
10484
|
-
if (!key) {
|
|
10485
|
-
i = readQuoted(raw, i)?.end ?? i + 1;
|
|
10486
|
-
continue;
|
|
10487
|
-
}
|
|
10488
|
-
i += key.length;
|
|
10489
|
-
let val;
|
|
10490
|
-
let j = i;
|
|
10491
|
-
while (j < raw.length && isWhitespace(raw[j])) j++;
|
|
10492
|
-
if (j > i) {
|
|
10493
|
-
const quoted = readQuoted(raw, j);
|
|
10494
|
-
const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
|
|
10495
|
-
if (quoted) {
|
|
10496
|
-
val = quoted.value;
|
|
10497
|
-
i = quoted.end;
|
|
10498
|
-
} else if (digits) {
|
|
10499
|
-
val = digits;
|
|
10500
|
-
i = j + digits.length;
|
|
10501
|
-
}
|
|
10502
|
-
}
|
|
10503
|
-
switch (key) {
|
|
10504
|
-
case "goto":
|
|
10505
|
-
options.goto = val;
|
|
10506
|
-
break;
|
|
10507
|
-
case "dialog":
|
|
10508
|
-
options.dialog = val;
|
|
10509
|
-
break;
|
|
10510
|
-
case "run":
|
|
10511
|
-
options.run = val;
|
|
10512
|
-
break;
|
|
10513
|
-
case "name":
|
|
10514
|
-
options.name = val;
|
|
10515
|
-
break;
|
|
10516
|
-
case "priority":
|
|
10517
|
-
options.priority = val ? Number(val) : 0;
|
|
10518
|
-
break;
|
|
10519
|
-
case "once":
|
|
10520
|
-
options.once = true;
|
|
10521
|
-
break;
|
|
10522
|
-
}
|
|
10523
|
-
}
|
|
10524
|
-
return {
|
|
10525
|
-
condition: cond.value,
|
|
10526
|
-
options
|
|
10527
|
-
};
|
|
10528
|
-
}
|
|
10529
|
-
/**
|
|
10530
|
-
* Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
|
|
10531
|
-
* same escapes as `{watch}`, or a bare name.
|
|
10532
|
-
*/
|
|
10533
|
-
function parseUnwatchName(rawArgs) {
|
|
10534
|
-
const raw = rawArgs.trim();
|
|
10535
|
-
return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, "");
|
|
10536
|
-
}
|
|
10537
10480
|
defineMacro({
|
|
10538
10481
|
name: "watch",
|
|
10539
|
-
|
|
10482
|
+
parameters: [{
|
|
10483
|
+
name: "condition",
|
|
10484
|
+
type: "string",
|
|
10485
|
+
required: true
|
|
10486
|
+
}, {
|
|
10487
|
+
name: "options",
|
|
10488
|
+
type: "options",
|
|
10489
|
+
parameters: [
|
|
10490
|
+
{
|
|
10491
|
+
name: "goto",
|
|
10492
|
+
type: "string"
|
|
10493
|
+
},
|
|
10494
|
+
{
|
|
10495
|
+
name: "dialog",
|
|
10496
|
+
type: "string"
|
|
10497
|
+
},
|
|
10498
|
+
{
|
|
10499
|
+
name: "run",
|
|
10500
|
+
type: "string"
|
|
10501
|
+
},
|
|
10502
|
+
{
|
|
10503
|
+
name: "name",
|
|
10504
|
+
type: "string"
|
|
10505
|
+
},
|
|
10506
|
+
{
|
|
10507
|
+
name: "priority",
|
|
10508
|
+
type: "number"
|
|
10509
|
+
},
|
|
10510
|
+
{
|
|
10511
|
+
name: "once",
|
|
10512
|
+
type: "flag"
|
|
10513
|
+
}
|
|
10514
|
+
]
|
|
10515
|
+
}],
|
|
10516
|
+
render(_props, ctx) {
|
|
10540
10517
|
const { hooks } = ctx;
|
|
10541
|
-
const
|
|
10542
|
-
if (
|
|
10543
|
-
const { condition, options } = parsed;
|
|
10518
|
+
const { condition, options } = ctx.args;
|
|
10519
|
+
if (condition === void 0) return null;
|
|
10544
10520
|
const registered = hooks.useRef(false);
|
|
10545
10521
|
if (!registered.current) {
|
|
10546
10522
|
registered.current = true;
|
|
@@ -10551,8 +10527,13 @@ defineMacro({
|
|
|
10551
10527
|
});
|
|
10552
10528
|
defineMacro({
|
|
10553
10529
|
name: "unwatch",
|
|
10554
|
-
|
|
10555
|
-
|
|
10530
|
+
parameters: [{
|
|
10531
|
+
name: "name",
|
|
10532
|
+
type: "text",
|
|
10533
|
+
required: true
|
|
10534
|
+
}],
|
|
10535
|
+
render(_props, ctx) {
|
|
10536
|
+
const name = ctx.args.name ?? "";
|
|
10556
10537
|
const ran = ctx.hooks.useRef(false);
|
|
10557
10538
|
if (!ran.current) {
|
|
10558
10539
|
ran.current = true;
|