@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.
Files changed (47) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +1502 -1521
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/class-registry.ts +6 -182
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/execute-mutation.ts +17 -97
  39. package/src/hooks/use-render-options.ts +26 -0
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/src/saves/save-manager.ts +190 -259
  43. package/src/saves/storage.ts +229 -402
  44. package/src/store.ts +244 -310
  45. package/src/structural.ts +388 -0
  46. package/types/index.d.ts +69 -6
  47. package/types/tooling.d.ts +33 -1
@@ -32,28 +32,192 @@ function create() {
32
32
  return (initializer) => bindPreact(createStore()(initializer));
33
33
  }
34
34
  //#endregion
35
- //#region src/class-registry.ts
36
- var registry$1 = /* @__PURE__ */ new Map();
37
- var ctorToName = /* @__PURE__ */ new Map();
38
- function registerClass(name, ctor) {
39
- registry$1.set(name, ctor);
40
- ctorToName.set(ctor, name);
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
- * The registered name of the class `value` is an instance of, or undefined.
44
- * Looks the constructor up on the prototype, so an own property named
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 registeredClassName(value) {
48
- const proto = Object.getPrototypeOf(value);
49
- return proto === null ? void 0 : ctorToName.get(proto.constructor);
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
- function isPlainObject(value) {
52
- if (typeof value !== "object" || value === null) return false;
53
- const proto = Object.getPrototypeOf(value);
54
- return proto === Object.prototype || proto === null;
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
- if (registeredClassName(obj) !== void 0) {
96
- const copy = Object.create(Object.getPrototypeOf(obj));
97
- seen.set(obj, copy);
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$3(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
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
- var hasOwn$3 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
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$3(val, CLASS_TAG) ? tagged("__Object__", data) : data;
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$3(SPECIAL_NUMBERS, data.value);
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/components/macros/WidgetInvocation.tsx
3440
- function WidgetBody({ body, parentValues, ownKeys }) {
3441
- const nobr = useContext(NobrContext);
3442
- const inline = useContext(InlineContext);
3443
- const raw = useContext(RawTextContext);
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
- nobr,
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 parentValues = useContext(LocalsValuesContext);
3480
- const nobr = useContext(NobrContext);
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(WidgetBody, {
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 = useContext(NobrContext);
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 saves = /* @__PURE__ */ new Map();
4333
- const playthroughs = /* @__PURE__ */ new Map();
4334
- const meta = /* @__PURE__ */ new Map();
4335
- return {
4336
- type: "memory",
4337
- async putSave(record) {
4338
- saves.set(record.meta.id, copy(record));
4339
- },
4340
- async getSave(id) {
4341
- const record = saves.get(id);
4342
- return record && copy(record);
4343
- },
4344
- async deleteSave(id) {
4345
- saves.delete(id);
4346
- },
4347
- async getSavesByIfid(ifid) {
4348
- return [...saves.values()].filter((s) => s.meta.ifid === ifid).map(copy);
4349
- },
4350
- async deleteSavesByIfid(ifid) {
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
- var LS_SAVE_PREFIX = "spindle.save.";
4408
- var LS_PT_PREFIX = "spindle.pt.";
4409
- var LS_META_PREFIX = "spindle.meta.";
4410
- var LS_IDX_SAVES_PREFIX = "spindle.idx.saves.";
4411
- var LS_IDX_PT_PREFIX = "spindle.idx.pt.";
4412
- var LS_IDX_SAVES_PT_PREFIX = "spindle.idx.saves-pt.";
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
- lsSet(key, lsIndex(key).filter((e) => e !== entry));
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
- return {
4443
- type: "localstorage",
4444
- async putSave(record) {
4445
- const id = record.meta.id;
4446
- const previous = lsGet(`${LS_SAVE_PREFIX}${id}`);
4447
- if (previous && previous.meta.playthroughId !== record.meta.playthroughId) lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`, id);
4448
- lsSet(`${LS_SAVE_PREFIX}${id}`, record);
4449
- lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
4450
- lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4451
- },
4452
- async getSave(id) {
4453
- return lsGet(`${LS_SAVE_PREFIX}${id}`);
4454
- },
4455
- async deleteSave(id) {
4456
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4457
- if (record) {
4458
- lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
4459
- lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4460
- }
4461
- lsDel(`${LS_SAVE_PREFIX}${id}`);
4462
- },
4463
- async getSavesByIfid(ifid) {
4464
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4465
- const results = [];
4466
- for (const id of ids) {
4467
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4468
- if (record) results.push(record);
4469
- }
4470
- return results;
4471
- },
4472
- async deleteSavesByIfid(ifid) {
4473
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4474
- for (const id of ids) {
4475
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4476
- if (record) lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4477
- lsDel(`${LS_SAVE_PREFIX}${id}`);
4478
- }
4479
- lsDel(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4480
- },
4481
- async deleteSavesByPlaythrough(playthroughId) {
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 tx(storeName, mode) {
4564
- return openDB().then((db) => db.transaction(storeName, mode).objectStore(storeName));
4565
- }
4566
- function idbReq(req) {
4567
- return new Promise((resolve, reject) => {
4568
- req.onsuccess = () => resolve(req.result);
4569
- req.onerror = () => reject(req.error);
4570
- });
4571
- }
4572
- async function getAllFromIndex(storeName, indexName, value) {
4573
- return await idbReq((await tx(storeName, "readonly")).index(indexName).getAll(value)) ?? [];
4574
- }
4575
- return {
4576
- type: "indexeddb",
4577
- async putSave(record) {
4578
- await idbReq((await tx("saves", "readwrite")).put(record));
4579
- },
4580
- async getSave(id) {
4581
- return await idbReq((await tx("saves", "readonly")).get(id)) ?? void 0;
4582
- },
4583
- async deleteSave(id) {
4584
- await idbReq((await tx("saves", "readwrite")).delete(id));
4585
- },
4586
- async getSavesByIfid(ifid) {
4587
- return getAllFromIndex("saves", "ifid", ifid);
4588
- },
4589
- async deleteSavesByIfid(ifid) {
4590
- const records = await getAllFromIndex("saves", "ifid", ifid);
4591
- const store = await tx("saves", "readwrite");
4592
- for (const r of records) await idbReq(store.delete(r.meta.id));
4593
- },
4594
- async deleteSavesByPlaythrough(playthroughId) {
4595
- const records = await getAllFromIndex("saves", "playthroughId", playthroughId);
4596
- const store = await tx("saves", "readwrite");
4597
- const ids = [];
4598
- for (const r of records) {
4599
- await idbReq(store.delete(r.meta.id));
4600
- ids.push(r.meta.id);
4601
- }
4602
- return ids;
4603
- },
4604
- async putPlaythrough(record) {
4605
- await idbReq((await tx("playthroughs", "readwrite")).put(record));
4606
- },
4607
- async getPlaythroughsByIfid(ifid) {
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
- function startNewPlaythrough(ifid, id = crypto.randomUUID()) {
4806
- return inOrder(() => startNewPlaythroughNow(ifid, id));
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
- function adoptPlaythrough(ifid, id) {
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: (/* @__PURE__ */ new Date()).toISOString(),
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, crypto.randomUUID()),
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
- function createSave(ifid, playthroughId, payload, custom = {}) {
4911
- return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
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
- async function createSaveNow(ifid, playthroughId, payload, custom) {
5123
+ var createSave = queued(createSaveNow);
5124
+ async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
4914
5125
  const now = (/* @__PURE__ */ new Date()).toISOString();
4915
- const meta = {
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
- function overwriteSave(saveId, payload, custom, playthroughId) {
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 serializedPayload = deepClone(payload);
4962
- serializedPayload.variables = serialize(serializedPayload.variables);
4963
- serializedPayload.history = serializedPayload.history.map((m) => ({
4964
- ...m,
4965
- variables: serialize(m.variables)
4966
- }));
4967
- const updated = {
4968
- meta: {
4969
- ...existing.meta,
4970
- ...playthroughId ? { playthroughId } : {},
4971
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
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
- const ifid = record.meta.ifid;
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
- function getSavesGrouped(ifid) {
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 indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
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(indexKey, updated);
5093
- }
5094
- /** Record a named slot in the per-story slot index (no-op for the default slot). */
5095
- function addToSlotIndex(ifid, slot) {
5096
- if (!isNamedSlot(slot)) return Promise.resolve();
5097
- return updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
5098
- }
5099
- /** Remove a named slot from the per-story slot index (no-op for the default slot). */
5100
- function removeFromSlotIndex(ifid, slot) {
5101
- if (!isNamedSlot(slot)) return Promise.resolve();
5102
- return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
5103
- }
5104
- /** Named slots in the per-story slot index. */
5105
- async function getIndexedSlots(ifid) {
5106
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5107
- return (await (await getBackend()).getMeta(indexKey) ?? []).filter(isNamedSlot);
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 backend = await getBackend();
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
- isAutosave: !isNamedSlot(slot),
5137
- ...isNamedSlot(slot) ? { slot } : {},
5328
+ ...slotCustom(slot),
5138
5329
  ...custom
5139
5330
  });
5140
- await backend.setMeta(metaKey, record.meta.id);
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
- const backend = await getBackend();
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 backend = await getBackend();
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
- function populateKnownSaves(ifid) {
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
- function getSlotSaveInfo(ifid, slot) {
5194
- return inOrder(() => getSlotSaveInfoNow(ifid, slot));
5195
- }
5374
+ var getSlotSaveInfo = queued(getSlotSaveInfoNow);
5196
5375
  async function getSlotSaveInfoNow(ifid, slot) {
5197
- const backend = await getBackend();
5198
- const metaKey = slotMetaKey(ifid, slot);
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
- function listSlotSaves(ifid) {
5209
- return inOrder(async () => {
5210
- const result = [];
5211
- const defaultInfo = await getSlotSaveInfoNow(ifid);
5212
- if (defaultInfo) result.push(defaultInfo);
5213
- for (const slot of await getIndexedSlots(ifid)) {
5214
- const info = await getSlotSaveInfoNow(ifid, slot);
5215
- if (info) result.push(info);
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
- function deleteSlotSave(ifid, slot) {
5224
- const metaKey = slotMetaKey(ifid, slot);
5225
- return inOrder(async () => {
5226
- const backend = await getBackend();
5227
- const existingId = await backend.getMeta(metaKey);
5228
- if (!existingId) return;
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
- function exportSave(saveId) {
5261
- return inOrder(() => exportSaveNow(saveId));
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
- if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === record.meta.playthroughId)) {
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
- function exportSlotSave(ifid, slot) {
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
- isAutosave: !isNamedSlot(slot),
5330
- ...isNamedSlot(slot) ? { slot } : {}
5476
+ ...slotCustom(slot)
5331
5477
  };
5332
5478
  const backend = await getBackend();
5333
- const previousId = await backend.getMeta(metaKey);
5479
+ const previousId = await slotSaveId(ifid, slot);
5334
5480
  await backend.putSave(record);
5335
- await backend.setMeta(metaKey, record.meta.id);
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
- function getStorageInfo(ifid) {
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 = JSON.stringify(save.payload).length;
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
- function clearAllData() {
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
- for (const slot of [void 0, ...await getIndexedSlots(ifid)]) {
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$1(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
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$1(node, key) ? node[key] : void 0;
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$1(parent, key) ? {
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$1(before, root) || before[root] !== after[root]).map((root) => changeTo(after, [root]));
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$1(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
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$1(localsClone, key)) scopeUpdate(key, void 0);
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 (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
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
- passage: history[i].passage,
6199
- variables: serialize(variables),
6200
- timestamp: history[i].timestamp,
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
- variableBase = variables;
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 arrays
6493
- * (see mergeable()), to the snapshot's `target`. Array elements merge by
6494
- * index. Elements the hooks removed from an array's end are removed from
6495
- * the snapshot's array at the same indices, and elements they added are
6496
- * appended to it: where the passage resized the array, its indices do not
6497
- * line up with the snapshot's, and the passage resizes it again on load.
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
- if (Array.isArray(target)) {
6502
- const b = before;
6503
- const a = after;
6504
- const shared = Math.min(b.length, a.length, target.length);
6505
- for (let i = 0; i < shared; i++) mergeHookWrite(target, String(i), b[i], a[i], ancestors);
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?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
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 = createNamespace(deepClone(variableDefaults));
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.temporary = createNamespace();
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
- const { historyIndex } = get();
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 = createNamespace(deepClone(variableDefaults));
6835
- resetModuleState(deepClone(initialVars));
6816
+ const initialVars = startVariables(variableDefaults);
6836
6817
  set((state) => {
6837
- state.currentPassage = startPassage.name;
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 handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
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((state) => {
6868
- state.knownSaves = {
6869
- ...state.knownSaves,
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 handled(read.then(async (loaded) => {
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
- }).catch((err) => {
6898
- console.error("spindle: failed to load save", err);
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 Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
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 handled(deleteSlotSave(storyData.ifid, slot).then(() => {
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((state) => {
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
- const cleared = clearGameData(storyData.ifid).then(() => {
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 handled(deletion.then(async () => {
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
- }).catch((err) => {
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
- let vars = variableBase;
7005
- for (let i = 0; i < history.length; i++) {
7006
- if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
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
- const prng = entry?.prng !== void 0 ? entry.prng : payload.prng;
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$2(values, name)) values[name] = def.config.default;
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 match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
7626
- if (!match) return {
7627
- varName: rawArgs.trim(),
7628
- placeholder: ""
7629
- };
7630
- const varName = match[1].replace(/["']/g, "");
7631
- const quoted = match[2];
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: quoted ? readWholeQuoted(quoted) ?? quoted.slice(1, -1) : ""
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 nobr = useContext(NobrContext);
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
- nobr,
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.trim().split(/\s+/)[0]?.replace(/["']/g, "") ?? "";
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
- registerMacroText(config.name, config.text);
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
- const ran = ctx.hooks.useRef(false);
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
- function parseComputedArgs(rawArgs) {
8468
- const trimmed = rawArgs.trim();
8469
- let depth = 0;
8470
- for (let i = 0; i < trimmed.length; i++) {
8471
- const ch = trimmed[i];
8472
- if (ch === "(" || ch === "[" || ch === "{") depth++;
8473
- else if (ch === ")" || ch === "]" || ch === "}") depth--;
8474
- else if (ch === "=" && depth === 0) {
8475
- if (trimmed[i + 1] === "=") {
8476
- i++;
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
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
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
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
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} = parseComputedArgs(rawArgs));
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
- computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
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
- * Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
8564
- * whitespace outside string and template literals and bracket pairs; a
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: tokens[0],
8580
- maxExpr: tokens.slice(1).join(" "),
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
- render({ rawArgs }, ctx) {
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 } = parseMeterArgs(rawArgs);
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
- * Parse for-loop args: "@item, @i of $list" or "@item of $list"
8713
- */
8714
- function parseForArgs(rawArgs) {
8715
- const ofIdx = rawArgs.indexOf(" of ");
8716
- if (ofIdx === -1) throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
8717
- const varsPart = rawArgs.slice(0, ofIdx).trim();
8718
- const listExpr = rawArgs.slice(ofIdx + 4).trim();
8719
- const vars = varsPart.split(",").map((v) => v.trim());
8720
- const itemVar = vars[0];
8721
- const indexVar = vars.length > 1 ? vars[1] : null;
8722
- if (!itemVar.startsWith("@")) throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
8723
- if (indexVar && !indexVar.startsWith("@")) throw new Error(`{for} index variable must use @ prefix: got "${indexVar}"`);
8724
- checkVariableName(itemVar.slice(1), itemVar);
8725
- if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
8726
- return {
8727
- itemVar: itemVar.slice(1),
8728
- indexVar: indexVar ? indexVar.slice(1) : null,
8729
- listExpr
8730
- };
8731
- }
8732
- function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue, children }) {
8733
- const [localMutations, setLocalMutations] = useState(() => ({}));
8734
- const ownKeys = useMemo(() => ({
8735
- [itemVar]: itemValue,
8736
- ...indexVar ? { [indexVar]: indexValue } : void 0
8737
- }), [
8738
- itemVar,
8739
- itemValue,
8740
- indexVar,
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 parsed;
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
- const result = ctx.evaluate(listExpr);
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 } = parseForArgs(rawArgs);
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
- console.error(`spindle: Error in {do}${ctx.sourceLocation()}:`, err);
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$2(getValues(), key),
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
- render({ rawArgs, children = [] }, ctx) {
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 = parseDelay(rawArgs);
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 = useContext(NobrContext);
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 text = readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, "");
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 { display, passage } = parseLinkArgs(rawArgs);
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 defaultBranch = null;
9072
- for (let i = 1; i < branches.length; i++) {
9073
- const branch = branches[i];
9074
- if (branch.rawArgs === "") {
9075
- defaultBranch = branch;
9076
- continue;
9077
- }
9078
- try {
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
- if (defaultBranch) return /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(defaultBranch.children) });
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 switchValue = ctx.evaluate(rawArgs);
9093
- let defaultBranch = null;
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
- render({ rawArgs, children = [] }, ctx) {
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 = parseDelay(rawArgs);
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
- render({ rawArgs, children = [] }, ctx) {
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 = /\bnoclose\s*$/.test(rawArgs);
9345
- const labelRaw = rawArgs.replace(/\bnoclose\s*$/, "").trim();
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
- render({ rawArgs }, ctx) {
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 { nameExpr, inline } = parseIncludeArgs(rawArgs);
9799
- let passageName;
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
- render({ rawArgs }, ctx) {
9899
+ parameters: [{
9900
+ name: "passage",
9901
+ type: "expression",
9902
+ required: true
9903
+ }],
9904
+ render(_props, ctx) {
9836
9905
  ctx.hooks.useLayoutEffect(() => {
9837
- let passageName;
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
- const ran = ctx.hooks.useRef(false);
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 = rawArgs.trim();
9860
- try {
9861
- if (name.startsWith("$")) state.deleteVariable(name.slice(1));
9862
- else if (name.startsWith("_")) state.deleteTemporary(name.slice(1));
9863
- else if (name.startsWith("%")) state.deleteTransient(name.slice(1));
9864
- else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
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
- defineMacro({
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
- defineMacro({
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
- defineMacro({
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
- render({ rawArgs }, ctx) {
9970
- const label = parseCheckboxLabel(rawArgs);
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
- render({ rawArgs }, ctx) {
10024
- const { value: radioValue, label } = parseRadioArgs(rawArgs);
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
- if (config.type === "crossfade") {
10412
- if (!containerEl) {
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
- render(props, ctx) {
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 parsed = parseWatchArgs(props.rawArgs);
10542
- if (!parsed) return null;
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
- render(props, ctx) {
10555
- const name = parseUnwatchName(props.rawArgs);
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;