@rohal12/spindle 0.52.4 → 0.52.5

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.
@@ -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;
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}`);
41
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();
@@ -4322,6 +4565,54 @@ function renderNodes(nodes, options) {
4322
4565
  }
4323
4566
  //#endregion
4324
4567
  //#region src/saves/storage.ts
4568
+ /** A backend's tables, made by its `table` function. */
4569
+ function makeTables(table) {
4570
+ return {
4571
+ saves: table("saves", {
4572
+ ifid: (r) => r.meta.ifid,
4573
+ playthroughId: (r) => r.meta.playthroughId
4574
+ }),
4575
+ playthroughs: table("playthroughs", { ifid: (r) => r.ifid }),
4576
+ meta: table("meta", {})
4577
+ };
4578
+ }
4579
+ function createBackend(type, { saves, playthroughs, meta }, destroy) {
4580
+ /** Delete the records `field` finds, resolving to their keys. */
4581
+ async function deleteFound(table, keyOf, field, value) {
4582
+ const keys = (await table.find(field, value)).map(keyOf);
4583
+ for (const key of keys) await table.delete(key);
4584
+ return keys;
4585
+ }
4586
+ async function deleteMetaWhere(test) {
4587
+ for (const key of await meta.keys()) if (test(key)) await meta.delete(key);
4588
+ }
4589
+ const saveId = (r) => r.meta.id;
4590
+ const playthroughId = (r) => r.id;
4591
+ return {
4592
+ type,
4593
+ putSave: (record) => saves.put(record.meta.id, record),
4594
+ getSave: (id) => saves.get(id),
4595
+ deleteSave: (id) => saves.delete(id),
4596
+ getSavesByIfid: (ifid) => saves.find("ifid", ifid),
4597
+ deleteSavesByIfid: async (ifid) => {
4598
+ await deleteFound(saves, saveId, "ifid", ifid);
4599
+ },
4600
+ deleteSavesByPlaythrough: (id) => deleteFound(saves, saveId, "playthroughId", id),
4601
+ putPlaythrough: (record) => playthroughs.put(record.id, record),
4602
+ getPlaythroughsByIfid: (ifid) => playthroughs.find("ifid", ifid),
4603
+ deletePlaythroughsByIfid: async (ifid) => {
4604
+ await deleteFound(playthroughs, playthroughId, "ifid", ifid);
4605
+ },
4606
+ deletePlaythroughById: (id) => playthroughs.delete(id),
4607
+ getMeta: (key) => meta.get(key),
4608
+ setMeta: (key, value) => meta.put(key, value),
4609
+ deleteMeta: (key) => meta.delete(key),
4610
+ deleteMetaByPrefix: (prefix) => deleteMetaWhere((key) => key.startsWith(prefix)),
4611
+ deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
4612
+ getAllMetaKeys: () => meta.keys(),
4613
+ destroy
4614
+ };
4615
+ }
4325
4616
  /**
4326
4617
  * In-memory storage. Like the other backends it stores copies: records and
4327
4618
  * values go in and come out cloned, so changing a record after storing it,
@@ -4329,87 +4620,37 @@ function renderNodes(nodes, options) {
4329
4620
  */
4330
4621
  function createMemoryBackend() {
4331
4622
  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
- };
4623
+ const maps = [];
4624
+ function table(_name, indexes) {
4625
+ const rows = /* @__PURE__ */ new Map();
4626
+ maps.push(rows);
4627
+ return {
4628
+ get: async (key) => copy(rows.get(key)),
4629
+ put: async (key, value) => {
4630
+ rows.set(key, copy(value));
4631
+ },
4632
+ delete: async (key) => {
4633
+ rows.delete(key);
4634
+ },
4635
+ find: async (field, value) => [...rows.values()].filter((r) => indexes[field](r) === value).map(copy),
4636
+ keys: async () => [...rows.keys()]
4637
+ };
4638
+ }
4639
+ return createBackend("memory", makeTables(table), async () => {
4640
+ for (const rows of maps) rows.clear();
4641
+ });
4406
4642
  }
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.";
4643
+ /** Key prefixes: of the records, and of each index's ID lists. */
4644
+ var LS_PREFIXES = {
4645
+ saves: "spindle.save.",
4646
+ playthroughs: "spindle.pt.",
4647
+ meta: "spindle.meta."
4648
+ };
4649
+ var LS_INDEX_PREFIXES = {
4650
+ "saves.ifid": "spindle.idx.saves.",
4651
+ "saves.playthroughId": "spindle.idx.saves-pt.",
4652
+ "playthroughs.ifid": "spindle.idx.pt."
4653
+ };
4413
4654
  function lsGet(key) {
4414
4655
  const raw = localStorage.getItem(key);
4415
4656
  if (raw === null) return void 0;
@@ -4435,107 +4676,55 @@ function lsIndexAdd(key, entry) {
4435
4676
  lsSet(key, arr);
4436
4677
  }
4437
4678
  }
4679
+ /** Remove an ID from an index; an index left empty is removed. */
4438
4680
  function lsIndexRemove(key, entry) {
4439
- lsSet(key, lsIndex(key).filter((e) => e !== entry));
4681
+ const arr = lsIndex(key).filter((e) => e !== entry);
4682
+ if (arr.length > 0) lsSet(key, arr);
4683
+ else lsDel(key);
4440
4684
  }
4685
+ /** The keys of every stored item that start with `prefix`. */
4686
+ var lsKeys = (prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix));
4441
4687
  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
- };
4688
+ function table(name, fields) {
4689
+ const prefix = LS_PREFIXES[name];
4690
+ const indexes = Object.entries(fields).map(([field, read]) => ({
4691
+ field,
4692
+ read,
4693
+ prefix: LS_INDEX_PREFIXES[`${name}.${field}`]
4694
+ }));
4695
+ /** The index lists `record` is in. */
4696
+ const listsOf = (record) => indexes.map((index) => `${index.prefix}${index.read(record)}`);
4697
+ return {
4698
+ get: async (key) => lsGet(`${prefix}${key}`),
4699
+ async put(key, value) {
4700
+ const previous = lsGet(`${prefix}${key}`);
4701
+ const lists = listsOf(value);
4702
+ if (previous !== void 0) {
4703
+ for (const list of listsOf(previous)) if (!lists.includes(list)) lsIndexRemove(list, key);
4704
+ }
4705
+ lsSet(`${prefix}${key}`, value);
4706
+ for (const list of lists) lsIndexAdd(list, key);
4707
+ },
4708
+ async delete(key) {
4709
+ const record = lsGet(`${prefix}${key}`);
4710
+ if (record !== void 0) for (const list of listsOf(record)) lsIndexRemove(list, key);
4711
+ lsDel(`${prefix}${key}`);
4712
+ },
4713
+ async find(field, value) {
4714
+ const index = indexes.find((i) => i.field === field);
4715
+ const results = [];
4716
+ for (const id of lsIndex(`${index.prefix}${value}`)) {
4717
+ const record = lsGet(`${prefix}${id}`);
4718
+ if (record !== void 0) results.push(record);
4719
+ }
4720
+ return results;
4721
+ },
4722
+ keys: async () => lsKeys(prefix).map((key) => key.slice(prefix.length))
4723
+ };
4724
+ }
4725
+ return createBackend("localstorage", makeTables(table), async () => {
4726
+ for (const key of lsKeys("spindle.")) lsDel(key);
4727
+ });
4539
4728
  }
4540
4729
  var IDB_DB_NAME = "spindle";
4541
4730
  var IDB_DB_VERSION = 1;
@@ -4560,95 +4749,51 @@ function createIDBBackend() {
4560
4749
  });
4561
4750
  return dbPromise;
4562
4751
  }
4563
- function tx(storeName, mode) {
4564
- return openDB().then((db) => db.transaction(storeName, mode).objectStore(storeName));
4565
- }
4566
4752
  function idbReq(req) {
4567
4753
  return new Promise((resolve, reject) => {
4568
4754
  req.onsuccess = () => resolve(req.result);
4569
4755
  req.onerror = () => reject(req.error);
4570
4756
  });
4571
4757
  }
4572
- async function getAllFromIndex(storeName, indexName, value) {
4573
- return await idbReq((await tx(storeName, "readonly")).index(indexName).getAll(value)) ?? [];
4758
+ /**
4759
+ * An object store. Records hold their own key (keyPath); meta values are
4760
+ * stored as `{ key, value }` rows.
4761
+ */
4762
+ function table(name) {
4763
+ const rows = name === "meta";
4764
+ /** Run one request in a transaction of its own. */
4765
+ const request = async (mode, make) => {
4766
+ return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
4767
+ };
4768
+ return {
4769
+ async get(key) {
4770
+ const result = await request("readonly", (s) => s.get(key));
4771
+ return rows ? result?.value : result;
4772
+ },
4773
+ async put(key, value) {
4774
+ await request("readwrite", (s) => s.put(rows ? {
4775
+ key,
4776
+ value
4777
+ } : value));
4778
+ },
4779
+ async delete(key) {
4780
+ await request("readwrite", (s) => s.delete(key));
4781
+ },
4782
+ async find(field, value) {
4783
+ return await request("readonly", (s) => s.index(field).getAll(value)) ?? [];
4784
+ },
4785
+ keys: async () => await request("readonly", (s) => s.getAllKeys())
4786
+ };
4574
4787
  }
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
- };
4788
+ return createBackend("indexeddb", makeTables(table), async () => {
4789
+ (await openDB()).close();
4790
+ dbPromise = null;
4791
+ await new Promise((resolve, reject) => {
4792
+ const req = indexedDB.deleteDatabase(IDB_DB_NAME);
4793
+ req.onsuccess = () => resolve();
4794
+ req.onerror = () => reject(req.error);
4795
+ });
4796
+ });
4652
4797
  }
4653
4798
  var _backend = null;
4654
4799
  async function detectBackend() {
@@ -4797,15 +4942,17 @@ function inOrder(op) {
4797
4942
  operationQueue = run.catch(() => {});
4798
4943
  return run;
4799
4944
  }
4945
+ /** `op` (a `...Now` helper) as an operation that queues itself. */
4946
+ function queued(op) {
4947
+ return (...args) => inOrder(() => op(...args));
4948
+ }
4800
4949
  /**
4801
4950
  * Store a new playthrough and make it the story's current one. Pass `id` when
4802
4951
  * the caller has already switched to the playthrough (restart() does, so
4803
4952
  * saves issued before this resolves are tagged with it).
4804
4953
  */
4805
- function startNewPlaythrough(ifid, id = crypto.randomUUID()) {
4806
- return inOrder(() => startNewPlaythroughNow(ifid, id));
4807
- }
4808
- async function startNewPlaythroughNow(ifid, id) {
4954
+ var startNewPlaythrough = queued(startNewPlaythroughNow);
4955
+ async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
4809
4956
  const backend = await getBackend();
4810
4957
  const num = await nextPlaythroughNumber(ifid);
4811
4958
  const record = {
@@ -4850,19 +4997,26 @@ function currentPlaythroughKey(ifid) {
4850
4997
  * recorded again as "Imported", as importing the save would: it was not
4851
4998
  * started here, so it takes no number.
4852
4999
  */
4853
- function adoptPlaythrough(ifid, id) {
4854
- return inOrder(() => adoptPlaythroughNow(ifid, id));
4855
- }
5000
+ var adoptPlaythrough = queued(adoptPlaythroughNow);
4856
5001
  async function adoptPlaythroughNow(ifid, id) {
5002
+ const backend = await getBackend();
5003
+ await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
5004
+ const key = currentPlaythroughKey(ifid);
5005
+ if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
5006
+ }
5007
+ /**
5008
+ * Record the playthrough `id` of the story as "Imported", created at
5009
+ * `createdAt`, unless it has a record: a playthrough not started here takes
5010
+ * no number.
5011
+ */
5012
+ async function ensurePlaythrough(ifid, id, createdAt) {
4857
5013
  const backend = await getBackend();
4858
5014
  if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === id)) await backend.putPlaythrough({
4859
5015
  id,
4860
5016
  ifid,
4861
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5017
+ createdAt,
4862
5018
  label: "Imported"
4863
5019
  });
4864
- const key = currentPlaythroughKey(ifid);
4865
- if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
4866
5020
  }
4867
5021
  /**
4868
5022
  * Set up the save system for a story that boots: the story's current
@@ -4872,7 +5026,7 @@ async function adoptPlaythroughNow(ifid, id) {
4872
5026
  function establishPlaythrough(ifid) {
4873
5027
  return inOrder(async () => {
4874
5028
  return {
4875
- id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid, crypto.randomUUID()),
5029
+ id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
4876
5030
  knownSaves: await populateKnownSavesNow(ifid)
4877
5031
  };
4878
5032
  });
@@ -4907,12 +5061,35 @@ function saveWithHooks(slot, custom, beginCapture, write) {
4907
5061
  return Promise.reject(err);
4908
5062
  }
4909
5063
  }
4910
- function createSave(ifid, playthroughId, payload, custom = {}) {
4911
- return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
5064
+ /**
5065
+ * `payload` with `convert` applied to its live variables and to those of
5066
+ * every history moment.
5067
+ */
5068
+ function mapVariables(payload, convert) {
5069
+ return {
5070
+ ...payload,
5071
+ variables: convert(payload.variables),
5072
+ history: payload.history.map((m) => ({
5073
+ ...m,
5074
+ variables: convert(m.variables)
5075
+ }))
5076
+ };
5077
+ }
5078
+ /** The record to store for a save: `payload` serialized, and its size. */
5079
+ function storedRecord(meta, payload) {
5080
+ const serialized = mapVariables(deepClone(payload), serialize);
5081
+ return {
5082
+ meta: {
5083
+ ...meta,
5084
+ estimatedBytes: estimatePayloadBytes(serialized)
5085
+ },
5086
+ payload: serialized
5087
+ };
4912
5088
  }
4913
- async function createSaveNow(ifid, playthroughId, payload, custom) {
5089
+ var createSave = queued(createSaveNow);
5090
+ async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
4914
5091
  const now = (/* @__PURE__ */ new Date()).toISOString();
4915
- const meta = {
5092
+ const record = storedRecord({
4916
5093
  id: crypto.randomUUID(),
4917
5094
  ifid,
4918
5095
  playthroughId,
@@ -4921,18 +5098,7 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
4921
5098
  title: generateTitle(payload),
4922
5099
  passage: payload.passage,
4923
5100
  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;
5101
+ }, payload);
4936
5102
  await (await getBackend()).putSave(record);
4937
5103
  return record;
4938
5104
  }
@@ -4951,34 +5117,22 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
4951
5117
  * save itself rather than describing its content. `custom` is merged, as
4952
5118
  * before, so slot keys and metadata not passed again are kept.
4953
5119
  */
4954
- function overwriteSave(saveId, payload, custom, playthroughId) {
4955
- return inOrder(() => overwriteSaveNow(saveId, payload, custom, playthroughId));
4956
- }
5120
+ var overwriteSave = queued(overwriteSaveNow);
4957
5121
  async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
4958
5122
  const backend = await getBackend();
4959
5123
  const existing = await backend.getSave(saveId);
4960
5124
  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;
5125
+ const updated = storedRecord({
5126
+ ...existing.meta,
5127
+ ...playthroughId ? { playthroughId } : {},
5128
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5129
+ title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
5130
+ passage: payload.passage,
5131
+ ...custom != null ? { custom: {
5132
+ ...existing.meta.custom,
5133
+ ...custom
5134
+ } } : {}
5135
+ }, payload);
4982
5136
  await backend.putSave(updated);
4983
5137
  return updated;
4984
5138
  }
@@ -4988,15 +5142,11 @@ async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
4988
5142
  * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
4989
5143
  */
4990
5144
  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
- };
5145
+ return mapVariables(payload, deserialize);
4999
5146
  }
5147
+ /** The live payload of a stored save, if there is one. */
5148
+ var livePayload = (record) => record && deserializePayload(record.payload);
5149
+ queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
5000
5150
  /**
5001
5151
  * Delete a save record. If the default slot or a named slot holds it, that
5002
5152
  * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
@@ -5007,14 +5157,7 @@ function deleteSaveById(saveId) {
5007
5157
  const record = await backend.getSave(saveId);
5008
5158
  await backend.deleteSave(saveId);
5009
5159
  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
- }
5160
+ await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
5018
5161
  });
5019
5162
  }
5020
5163
  function renameSave(saveId, newTitle) {
@@ -5034,10 +5177,7 @@ function renameSave(saveId, newTitle) {
5034
5177
  await backend.putSave(updated);
5035
5178
  });
5036
5179
  }
5037
- function getSavesGrouped(ifid) {
5038
- return inOrder(() => getSavesGroupedNow(ifid));
5039
- }
5040
- async function getSavesGroupedNow(ifid) {
5180
+ var getSavesGrouped = queued(async function getSavesGroupedNow(ifid) {
5041
5181
  const backend = await getBackend();
5042
5182
  const [allSaves, allPlaythroughs] = await Promise.all([backend.getSavesByIfid(ifid), backend.getPlaythroughsByIfid(ifid)]);
5043
5183
  const ptMap = /* @__PURE__ */ new Map();
@@ -5069,7 +5209,7 @@ async function getSavesGroupedNow(ifid) {
5069
5209
  saves
5070
5210
  });
5071
5211
  return result;
5072
- }
5212
+ });
5073
5213
  var AUTOSAVE_KEY_PREFIX = "autosave.";
5074
5214
  var SLOT_KEY_PREFIX = "slot.";
5075
5215
  var SLOT_INDEX_KEY_PREFIX = "slotIndex.";
@@ -5083,29 +5223,49 @@ function isNamedSlot(slot) {
5083
5223
  function slotMetaKey(ifid, slot) {
5084
5224
  return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
5085
5225
  }
5226
+ var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5086
5227
  /** Read-modify-write the per-story slot index. */
5087
5228
  async function updateSlotIndex(ifid, update) {
5088
5229
  const backend = await getBackend();
5089
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5090
- const existing = await backend.getMeta(indexKey) ?? [];
5230
+ const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
5091
5231
  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
- }
5232
+ if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
5233
+ }
5234
+ /** Every slot: the default one (undefined), then those in the slot index. */
5235
+ async function allSlots(ifid) {
5236
+ return [void 0, ...(await (await getBackend()).getMeta(slotIndexKey(ifid)) ?? []).filter(isNamedSlot)];
5237
+ }
5238
+ /** The ID of the save a slot holds, if any. */
5239
+ async function slotSaveId(ifid, slot) {
5240
+ return (await getBackend()).getMeta(slotMetaKey(ifid, slot));
5241
+ }
5242
+ /** The record of the save a slot holds, if there is one. */
5243
+ async function slotRecord(ifid, slot) {
5244
+ const id = await slotSaveId(ifid, slot);
5245
+ return id ? (await getBackend()).getSave(id) : void 0;
5246
+ }
5247
+ /** Make a slot hold the save `saveId`: its pointer, and the slot index. */
5248
+ async function fillSlot(ifid, slot, saveId) {
5249
+ await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
5250
+ if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
5251
+ }
5252
+ /** Clear a slot: its pointer, and its entry in the slot index. */
5253
+ async function emptySlot(ifid, slot) {
5254
+ await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
5255
+ if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
5256
+ }
5257
+ /** Clear every slot holding a save whose ID passes `test`. */
5258
+ async function emptySlotsHolding(ifid, test) {
5259
+ for (const slot of await allSlots(ifid)) {
5260
+ const id = await slotSaveId(ifid, slot);
5261
+ if (id && test(id)) await emptySlot(ifid, slot);
5262
+ }
5263
+ }
5264
+ /** The `custom` keys that say which slot a save lives in. */
5265
+ var slotCustom = (slot) => ({
5266
+ isAutosave: !isNamedSlot(slot),
5267
+ ...isNamedSlot(slot) ? { slot } : {}
5268
+ });
5109
5269
  function toSaveInfo(record, slot) {
5110
5270
  return {
5111
5271
  slot: isNamedSlot(slot) ? slot : "",
@@ -5123,32 +5283,26 @@ function toSaveInfo(record, slot) {
5123
5283
  * place in the order of operations when it is called.
5124
5284
  */
5125
5285
  function quickSave(ifid, playthroughId, payload, slot, custom) {
5126
- const metaKey = slotMetaKey(ifid, slot);
5127
5286
  return inOrder(async () => {
5128
5287
  const ptId = await playthroughId;
5129
- const backend = await getBackend();
5130
- const existingId = await backend.getMeta(metaKey);
5288
+ const existingId = await slotSaveId(ifid, slot);
5131
5289
  if (existingId) {
5132
5290
  const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
5133
5291
  if (updated) return updated;
5134
5292
  }
5135
5293
  const record = await createSaveNow(ifid, ptId, payload, {
5136
- isAutosave: !isNamedSlot(slot),
5137
- ...isNamedSlot(slot) ? { slot } : {},
5294
+ ...slotCustom(slot),
5138
5295
  ...custom
5139
5296
  });
5140
- await backend.setMeta(metaKey, record.meta.id);
5141
- await addToSlotIndex(ifid, slot);
5297
+ await fillSlot(ifid, slot, record.meta.id);
5142
5298
  return record;
5143
5299
  });
5144
5300
  }
5301
+ queued(hasQuickSaveNow);
5145
5302
  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;
5303
+ return await slotRecord(ifid, slot) !== void 0;
5151
5304
  }
5305
+ queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
5152
5306
  /**
5153
5307
  * Read the save held in a slot (the default slot when `slot` is omitted) for
5154
5308
  * the running game to load, and make its playthrough the story's current one
@@ -5157,12 +5311,8 @@ async function hasQuickSaveNow(ifid, slot) {
5157
5311
  * and its playthrough, or undefined if the slot is empty.
5158
5312
  */
5159
5313
  function loadSlotSave(ifid, slot) {
5160
- const metaKey = slotMetaKey(ifid, slot);
5161
5314
  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);
5315
+ const record = await slotRecord(ifid, slot);
5166
5316
  if (!record) return void 0;
5167
5317
  const payload = deserializePayload(record.payload);
5168
5318
  const { playthroughId } = record.meta;
@@ -5177,60 +5327,41 @@ function loadSlotSave(ifid, slot) {
5177
5327
  * Check storage for all known saves and return a map of slot keys to true.
5178
5328
  * The default (autosave) slot uses empty string as key.
5179
5329
  */
5180
- function populateKnownSaves(ifid) {
5181
- return inOrder(() => populateKnownSavesNow(ifid));
5182
- }
5330
+ var populateKnownSaves = queued(populateKnownSavesNow);
5183
5331
  async function populateKnownSavesNow(ifid) {
5184
5332
  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;
5333
+ for (const slot of await allSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ""] = true;
5187
5334
  return result;
5188
5335
  }
5189
5336
  /**
5190
5337
  * Get metadata for a specific save slot.
5191
5338
  * Returns null if no save exists for that slot.
5192
5339
  */
5193
- function getSlotSaveInfo(ifid, slot) {
5194
- return inOrder(() => getSlotSaveInfoNow(ifid, slot));
5195
- }
5340
+ var getSlotSaveInfo = queued(getSlotSaveInfoNow);
5196
5341
  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);
5342
+ const record = await slotRecord(ifid, slot);
5343
+ return record ? toSaveInfo(record, slot) : null;
5204
5344
  }
5205
5345
  /**
5206
5346
  * List metadata for all known save slots (default + named).
5207
5347
  */
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
- }
5348
+ var listSlotSaves = queued(async (ifid) => {
5349
+ const result = [];
5350
+ for (const slot of await allSlots(ifid)) {
5351
+ const info = await getSlotSaveInfoNow(ifid, slot);
5352
+ if (info) result.push(info);
5353
+ }
5354
+ return result;
5355
+ });
5220
5356
  /**
5221
5357
  * Delete a save by slot name. Removes from slot index if named.
5222
5358
  */
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
- }
5359
+ var deleteSlotSave = queued(async (ifid, slot) => {
5360
+ const existingId = await slotSaveId(ifid, slot);
5361
+ if (!existingId) return;
5362
+ await (await getBackend()).deleteSave(existingId);
5363
+ await emptySlot(ifid, slot);
5364
+ });
5234
5365
  var SESSION_KEY_PREFIX = "spindle.session.";
5235
5366
  /**
5236
5367
  * Write a pre-serialized session payload to sessionStorage.
@@ -5257,11 +5388,8 @@ function clearSession(ifid) {
5257
5388
  sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
5258
5389
  } catch {}
5259
5390
  }
5260
- function exportSave(saveId) {
5261
- return inOrder(() => exportSaveNow(saveId));
5262
- }
5263
- async function exportSaveNow(saveId) {
5264
- const record = await (await getBackend()).getSave(saveId);
5391
+ /** The export of a stored save, if there is one. */
5392
+ function toExport(record) {
5265
5393
  if (!record) return void 0;
5266
5394
  return {
5267
5395
  version: 1,
@@ -5270,6 +5398,7 @@ async function exportSaveNow(saveId) {
5270
5398
  save: record
5271
5399
  };
5272
5400
  }
5401
+ var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
5273
5402
  /**
5274
5403
  * Validate an export against the running story and build the record to store:
5275
5404
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
@@ -5279,20 +5408,11 @@ async function prepareImport(data, ifid) {
5279
5408
  if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
5280
5409
  if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
5281
5410
  if (!isSaveExport(data)) throw new Error("Invalid save file format");
5282
- const backend = await getBackend();
5283
5411
  const record = deepClone(data.save);
5284
5412
  record.meta.id = crypto.randomUUID();
5285
5413
  record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5286
5414
  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
- }
5415
+ await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
5296
5416
  return record;
5297
5417
  }
5298
5418
  function importSave(data, ifid) {
@@ -5306,13 +5426,7 @@ function importSave(data, ifid) {
5306
5426
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
5307
5427
  * Returns undefined if the slot is empty.
5308
5428
  */
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
- }
5429
+ var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
5316
5430
  /**
5317
5431
  * Import an exported save into a slot (default autosave slot when `slot` is
5318
5432
  * omitted), replacing any save already in that slot. Applies the same
@@ -5320,35 +5434,29 @@ function exportSlotSave(ifid, slot) {
5320
5434
  * and slot index consistent.
5321
5435
  */
5322
5436
  function importSlotSave(data, ifid, slot) {
5323
- const metaKey = slotMetaKey(ifid, slot);
5324
5437
  return inOrder(async () => {
5325
5438
  const record = await prepareImport(data, ifid);
5326
5439
  const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
5327
5440
  record.meta.custom = {
5328
5441
  ...custom,
5329
- isAutosave: !isNamedSlot(slot),
5330
- ...isNamedSlot(slot) ? { slot } : {}
5442
+ ...slotCustom(slot)
5331
5443
  };
5332
5444
  const backend = await getBackend();
5333
- const previousId = await backend.getMeta(metaKey);
5445
+ const previousId = await slotSaveId(ifid, slot);
5334
5446
  await backend.putSave(record);
5335
- await backend.setMeta(metaKey, record.meta.id);
5336
- await addToSlotIndex(ifid, slot);
5447
+ await fillSlot(ifid, slot, record.meta.id);
5337
5448
  if (previousId) await backend.deleteSave(previousId);
5338
5449
  return toSaveInfo(record, slot);
5339
5450
  });
5340
5451
  }
5341
- function getStorageInfo(ifid) {
5342
- return inOrder(() => getStorageInfoNow(ifid));
5343
- }
5344
- async function getStorageInfoNow(ifid) {
5452
+ var getStorageInfo = queued(async function getStorageInfoNow(ifid) {
5345
5453
  const backend = await getBackend();
5346
5454
  const saves = await backend.getSavesByIfid(ifid);
5347
5455
  const playthroughs = await backend.getPlaythroughsByIfid(ifid);
5348
5456
  let totalBytes = 0;
5349
5457
  for (const save of saves) if (save.meta.estimatedBytes != null) totalBytes += save.meta.estimatedBytes;
5350
5458
  else {
5351
- const bytes = JSON.stringify(save.payload).length;
5459
+ const bytes = estimatePayloadBytes(save.payload);
5352
5460
  save.meta.estimatedBytes = bytes;
5353
5461
  await backend.putSave(save);
5354
5462
  totalBytes += bytes;
@@ -5359,7 +5467,7 @@ async function getStorageInfoNow(ifid) {
5359
5467
  totalBytes,
5360
5468
  backend: backend.type
5361
5469
  };
5362
- }
5470
+ });
5363
5471
  function clearGameData(ifid) {
5364
5472
  return inOrder(async () => {
5365
5473
  const backend = await getBackend();
@@ -5369,9 +5477,7 @@ function clearGameData(ifid) {
5369
5477
  clearSession(ifid);
5370
5478
  });
5371
5479
  }
5372
- function clearAllData() {
5373
- return inOrder(clearAllDataNow);
5374
- }
5480
+ var clearAllData = queued(clearAllDataNow);
5375
5481
  async function clearAllDataNow() {
5376
5482
  await (await getBackend()).destroy();
5377
5483
  resetBackend();
@@ -5399,14 +5505,7 @@ function deletePlaythroughData(ifid, playthroughId, replacement) {
5399
5505
  const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
5400
5506
  await backend.deletePlaythroughById(playthroughId);
5401
5507
  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
- }
5508
+ await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
5410
5509
  if (replacement && await replacement.current === playthroughId) {
5411
5510
  await startNewPlaythroughNow(ifid, replacement.id);
5412
5511
  return true;
@@ -5467,120 +5566,6 @@ function initializeStory(session) {
5467
5566
  if (!session) recordStoryInitState();
5468
5567
  }
5469
5568
  //#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
5569
  //#region src/execute-mutation.ts
5585
5570
  var NAMESPACES = [
5586
5571
  "variables",
@@ -5622,54 +5607,6 @@ function frozenCopy(value) {
5622
5607
  return freeze(cloneValue(value), true);
5623
5608
  }
5624
5609
  /**
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
5610
  * Apply a write that reached the store while mutations execute to their
5674
5611
  * `work` and `base` copies (see MutationScope), so the code sees it and
5675
5612
  * its commit neither drops it nor reverts it. Called from inside the store
@@ -5687,7 +5624,7 @@ function mirror(draft, ns, change, scopes = activeScopes) {
5687
5624
  });
5688
5625
  } catch {
5689
5626
  const stored = draft[ns][root];
5690
- if (hasOwn$1(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
5627
+ if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
5691
5628
  else delete copy[root];
5692
5629
  }
5693
5630
  }
@@ -5711,7 +5648,7 @@ function changedPath(ns, segments) {
5711
5648
  if (!isMergeable(node)) break;
5712
5649
  const key = String(segment);
5713
5650
  path.push(key);
5714
- node = hasOwn$1(node, key) ? node[key] : void 0;
5651
+ node = hasOwn(node, key) ? node[key] : void 0;
5715
5652
  }
5716
5653
  return path;
5717
5654
  }
@@ -5719,7 +5656,7 @@ function changedPath(ns, segments) {
5719
5656
  function changeTo(ns, path) {
5720
5657
  const parent = getByPath(ns, path.slice(0, -1));
5721
5658
  const key = path[path.length - 1];
5722
- return isMergeable(parent) && hasOwn$1(parent, key) ? {
5659
+ return isMergeable(parent) && hasOwn(parent, key) ? {
5723
5660
  path,
5724
5661
  deleted: false,
5725
5662
  value: parent[key]
@@ -5735,7 +5672,7 @@ function changeTo(ns, path) {
5735
5672
  * replaces exactly what it replaced in program order.
5736
5673
  */
5737
5674
  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]));
5675
+ 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
5676
  const seen = /* @__PURE__ */ new Set();
5740
5677
  const changes = [];
5741
5678
  for (const patch of patches) {
@@ -5807,7 +5744,7 @@ function routeStoreUpdate(recipe) {
5807
5744
  try {
5808
5745
  applyChange(namespaces[ns], owned(change));
5809
5746
  } catch {
5810
- if (hasOwn$1(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
5747
+ if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
5811
5748
  else delete namespaces[ns][root];
5812
5749
  }
5813
5750
  mirror(namespaces, ns, change);
@@ -5838,11 +5775,7 @@ function commitScopes(commits) {
5838
5775
  const all = commits.map(({ scope, keepRunning }) => ({
5839
5776
  scope,
5840
5777
  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
- })
5778
+ changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
5846
5779
  }));
5847
5780
  const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
5848
5781
  if (changed.length === 0) return;
@@ -5890,12 +5823,7 @@ var running = () => activeScopes.map((scope) => ({
5890
5823
  */
5891
5824
  function resync(scope) {
5892
5825
  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
- }
5826
+ for (const ns of NAMESPACES) mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
5899
5827
  scope.base = cloneNamespaces(state);
5900
5828
  }
5901
5829
  /**
@@ -5934,7 +5862,7 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
5934
5862
  keepRunning: false
5935
5863
  }]);
5936
5864
  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);
5865
+ for (const key of Object.keys(mergedLocals)) if (!hasOwn(localsClone, key)) scopeUpdate(key, void 0);
5938
5866
  }
5939
5867
  //#endregion
5940
5868
  //#region src/triggers.ts
@@ -6164,7 +6092,7 @@ function clonePatches(patches) {
6164
6092
  function computeVarPatches(prev, curr) {
6165
6093
  const [, forward, inverse] = produceWithPatches(prev, (draft) => {
6166
6094
  const d = draft;
6167
- for (const key of Object.keys(d)) if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
6095
+ for (const key of Object.keys(d)) if (!hasOwn(curr, key)) delete d[key];
6168
6096
  for (const [key, val] of Object.entries(curr)) d[key] = val;
6169
6097
  });
6170
6098
  return {
@@ -6181,6 +6109,26 @@ function rerecordNewestMoment(vars) {
6181
6109
  if (last < 0) variableBase = vars;
6182
6110
  else patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
6183
6111
  }
6112
+ /**
6113
+ * Call `fn` with the variables recorded for each of the first `count`
6114
+ * history moments, replaying the patches once.
6115
+ */
6116
+ function forEachRecorded(count, fn) {
6117
+ let vars = variableBase;
6118
+ for (let i = 0; i < count; i++) {
6119
+ if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
6120
+ fn(vars, i);
6121
+ }
6122
+ }
6123
+ /** A history moment as saves and the session hold it, with its variables. */
6124
+ function savedMoment(moment, variables) {
6125
+ return {
6126
+ passage: moment.passage,
6127
+ variables,
6128
+ timestamp: moment.timestamp,
6129
+ prng: moment.prng
6130
+ };
6131
+ }
6184
6132
  /** Reconstruct variables at a given history moment by replaying patches. */
6185
6133
  function reconstructVarsAt(index) {
6186
6134
  let vars = variableBase;
@@ -6194,24 +6142,10 @@ function persistSession(get) {
6194
6142
  if (serializedHistory.length > history.length) serializedHistory.length = history.length;
6195
6143
  if (serializedHistory.length < history.length) if (history.length - serializedHistory.length === 1) {
6196
6144
  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
- }
6145
+ serializedHistory[i] = savedMoment(history[i], serialize(variables));
6146
+ } else forEachRecorded(history.length, (vars, i) => {
6147
+ if (i >= serializedHistory.length) serializedHistory[i] = savedMoment(history[i], serialize(vars));
6148
+ });
6215
6149
  saveSession(storyData.ifid, {
6216
6150
  passage: currentPassage,
6217
6151
  variables: serialize(variables),
@@ -6283,6 +6217,58 @@ function resetModuleState(base) {
6283
6217
  serializedHistory = [];
6284
6218
  }
6285
6219
  /**
6220
+ * Fresh variables from `defaults` for a game that starts over (init,
6221
+ * restart), recorded as the history base.
6222
+ */
6223
+ function startVariables(defaults) {
6224
+ const initialVars = createNamespace(deepClone(defaults));
6225
+ resetModuleState(deepClone(initialVars));
6226
+ return initialVars;
6227
+ }
6228
+ /**
6229
+ * Enter the start passage with `variables`, as the only history moment
6230
+ * (init, restart). Call it inside a store update.
6231
+ */
6232
+ function enterStart(state, passage, variables, transientDefaults) {
6233
+ state.currentPassage = passage;
6234
+ state.navigationId++;
6235
+ state.variables = variables;
6236
+ state.transient = createNamespace(deepClone(transientDefaults));
6237
+ state.temporary = createNamespace();
6238
+ state.history = [{
6239
+ passage,
6240
+ timestamp: Date.now()
6241
+ }];
6242
+ state.historyIndex = 0;
6243
+ }
6244
+ /**
6245
+ * Move through history by `step` moments (back or forward), restoring the
6246
+ * snapshot recorded for the moment moved to: live variables may hold edits
6247
+ * made since the current moment was recorded.
6248
+ */
6249
+ function moveInHistory(get, set, step) {
6250
+ const { historyIndex, history } = get();
6251
+ const target = historyIndex + step;
6252
+ if (target < 0 || target >= history.length) return;
6253
+ finishEnteredMoment(get, set);
6254
+ const previousPassage = get().currentPassage;
6255
+ const targetPassage = history[target].passage;
6256
+ emit("beforenavigate", targetPassage);
6257
+ const restoredVars = deepClone(reconstructVarsAt(target));
6258
+ set((state) => {
6259
+ state.historyIndex += step;
6260
+ state.currentPassage = state.history[state.historyIndex].passage;
6261
+ state.navigationId++;
6262
+ state.variables = restoredVars;
6263
+ state.temporary = createNamespace();
6264
+ });
6265
+ reinitTriggerState();
6266
+ lastNavigationVars = get().variables;
6267
+ restorePRNGFromMoment(get().history[get().historyIndex]);
6268
+ persistSession(get);
6269
+ emit("afternavigate", targetPassage, previousPassage);
6270
+ }
6271
+ /**
6286
6272
  * Record the state StoryInit left behind as the start moment: its variable
6287
6273
  * snapshot (the history base) and PRNG state. Called by executeStoryInit()
6288
6274
  * after the StoryInit passage has rendered, and again after the `storyinit`
@@ -6293,10 +6279,7 @@ function resetModuleState(base) {
6293
6279
  function recordStoryInitState() {
6294
6280
  const { history, historyIndex, variables } = useStoryStore.getState();
6295
6281
  if (history.length !== 1 || historyIndex !== 0) return;
6296
- variableBase = variables;
6297
- patchEntries = [];
6298
- lastNavigationVars = variables;
6299
- serializedHistory = [];
6282
+ resetModuleState(variables);
6300
6283
  const prng = snapshotPRNG();
6301
6284
  useStoryStore.setState((state) => {
6302
6285
  state.history[0].prng = prng;
@@ -6466,56 +6449,31 @@ function keepHookWrites(payload, before, after) {
6466
6449
  if (!moment) return;
6467
6450
  mergeHookWrites(moment.variables, before, after, /* @__PURE__ */ new Set());
6468
6451
  }
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
6452
  /**
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.
6453
+ * Apply the hooks' changes between `before` and `after`, two objects or two
6454
+ * arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
6455
+ * elements merge by index). A changed value is merged key by key where the
6456
+ * snapshot holds the same kind of value. Where it lacks the key or holds
6457
+ * another kind (the passage created or replaced it), the hooks' whole value
6458
+ * is written.
6498
6459
  */
6499
6460
  function mergeHookWrites(target, before, after, ancestors) {
6500
6461
  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
- }
6462
+ mergeKeys(target, before, after, (key, b, a) => {
6463
+ if (!hasOwn(target, key) || !mergesWith(a, target[key], true) || ancestors.has(a)) return false;
6464
+ mergeHookWrites(target[key], b, a, ancestors);
6465
+ return true;
6466
+ });
6513
6467
  ancestors.delete(after);
6514
6468
  }
6469
+ /** Restore the PRNG from a snapshot, or reset it without one. */
6470
+ function restorePRNGFrom(prng) {
6471
+ if (prng) restorePRNG(prng.seed, prng.pull);
6472
+ else resetPRNG();
6473
+ }
6515
6474
  /** Restore or reset PRNG from a history moment's snapshot. */
6516
6475
  function restorePRNGFromMoment(moment) {
6517
- if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
6518
- else if (moment) resetPRNG();
6476
+ if (moment) restorePRNGFrom(moment.prng);
6519
6477
  }
6520
6478
  var NAMESPACE_KEYS = [
6521
6479
  "variables",
@@ -6598,6 +6556,46 @@ function handled(p) {
6598
6556
  p.catch(() => {});
6599
6557
  return p;
6600
6558
  }
6559
+ /**
6560
+ * `p` with its failure logged under `message` and passed to `onError`, and
6561
+ * still rejecting; marked as handled (see handled()).
6562
+ */
6563
+ function reported(p, message, onError) {
6564
+ return handled(p.catch((err) => {
6565
+ console.error(message, err);
6566
+ onError?.(err);
6567
+ throw err;
6568
+ }));
6569
+ }
6570
+ /** Record in the slot cache whether `slot` holds a save. */
6571
+ function recordKnownSave(set, slot, known) {
6572
+ set((state) => {
6573
+ const key = slot ?? "";
6574
+ if (known) state.knownSaves = {
6575
+ ...state.knownSaves,
6576
+ [key]: true
6577
+ };
6578
+ else {
6579
+ const { [key]: _, ...rest } = state.knownSaves;
6580
+ state.knownSaves = rest;
6581
+ }
6582
+ });
6583
+ }
6584
+ /**
6585
+ * Queue the clearing of saved data, then restart now: the new playthrough
6586
+ * is stored after it, and operations issued from here on belong to the new
6587
+ * game. The slot cache empties once the clearing is done, after operations
6588
+ * issued before it have updated it.
6589
+ */
6590
+ function clearAndRestart(get, set, clearing, message) {
6591
+ const cleared = clearing.then(() => {
6592
+ set((state) => {
6593
+ state.knownSaves = {};
6594
+ });
6595
+ });
6596
+ get().restart();
6597
+ return reported(cleared, message);
6598
+ }
6601
6599
  var useStoryStore = create()(storyStore((set, get) => ({
6602
6600
  storyData: null,
6603
6601
  currentPassage: "",
@@ -6643,23 +6641,13 @@ var useStoryStore = create()(storyStore((set, get) => ({
6643
6641
  init: (storyData, variableDefaults = {}, transientDefaults = {}) => {
6644
6642
  const startPassage = storyData.passagesById.get(storyData.startNode);
6645
6643
  if (!startPassage) throw new Error(`spindle: Start passage (pid=${storyData.startNode}) not found.`);
6646
- const initialVars = createNamespace(deepClone(variableDefaults));
6647
- resetModuleState(deepClone(initialVars));
6644
+ const initialVars = startVariables(variableDefaults);
6648
6645
  set((state) => {
6649
6646
  state.storyData = storyData;
6650
6647
  state.playthroughId = "";
6651
- state.currentPassage = startPassage.name;
6652
- state.navigationId++;
6653
- state.variables = initialVars;
6654
6648
  state.variableDefaults = variableDefaults;
6655
- state.transient = createNamespace(deepClone(transientDefaults));
6656
6649
  state.transientDefaults = transientDefaults;
6657
- state.temporary = createNamespace();
6658
- state.history = [{
6659
- passage: startPassage.name,
6660
- timestamp: Date.now()
6661
- }];
6662
- state.historyIndex = 0;
6650
+ enterStart(state, startPassage.name, initialVars, transientDefaults);
6663
6651
  state.visitCounts = createCounts(null, startPassage.name);
6664
6652
  state.renderCounts = createCounts(null, startPassage.name);
6665
6653
  });
@@ -6728,48 +6716,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
6728
6716
  emit("afternavigate", passageName, previousPassage);
6729
6717
  for (const next of deferred) get().navigate(next);
6730
6718
  },
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
- },
6719
+ goBack: () => moveInHistory(get, set, -1),
6720
+ goForward: () => moveInHistory(get, set, 1),
6773
6721
  setVariable: (name, value) => {
6774
6722
  set((state) => {
6775
6723
  checkVariableName(name, `$${name}`);
@@ -6831,19 +6779,9 @@ var useStoryStore = create()(storyStore((set, get) => ({
6831
6779
  cleanupRuntimeHandlers();
6832
6780
  resetPRNG();
6833
6781
  resetTriggers();
6834
- const initialVars = createNamespace(deepClone(variableDefaults));
6835
- resetModuleState(deepClone(initialVars));
6782
+ const initialVars = startVariables(variableDefaults);
6836
6783
  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;
6784
+ enterStart(state, startPassage.name, initialVars, transientDefaults);
6847
6785
  state.visitCounts = createCounts(null, startPassage.name);
6848
6786
  state.renderCounts = createCounts(null, startPassage.name);
6849
6787
  if (!keepDeferred) state.renderDeferred = false;
@@ -6859,23 +6797,14 @@ var useStoryStore = create()(storyStore((set, get) => ({
6859
6797
  const { storyData } = get();
6860
6798
  if (!storyData) return Promise.resolve();
6861
6799
  const playthrough = resolvePlaythroughId();
6862
- return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
6800
+ return reported(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
6863
6801
  set((state) => {
6864
6802
  state.saveError = null;
6865
6803
  });
6866
6804
  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;
6805
+ recordKnownSave(set, slot, true);
6806
+ }), "spindle: failed to save", (err) => set((state) => {
6807
+ state.saveError = errorMessage(err, "Failed to save");
6879
6808
  }));
6880
6809
  },
6881
6810
  load: (slot) => {
@@ -6888,24 +6817,20 @@ var useStoryStore = create()(storyStore((set, get) => ({
6888
6817
  const read = loadSlotSave(storyData.ifid, slot);
6889
6818
  const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
6890
6819
  const replacement = ++stateReplacementsIssued;
6891
- return handled(read.then(async (loaded) => {
6820
+ return reported(read.then(async (loaded) => {
6892
6821
  await switched;
6893
6822
  if (!loaded) return;
6894
6823
  if (latestStateApplied > replacement) return;
6895
6824
  slotLoadApplying = replacement;
6896
6825
  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;
6826
+ }), "spindle: failed to load save", (err) => set((state) => {
6827
+ state.loadError = errorMessage(err, "Failed to load");
6903
6828
  }));
6904
6829
  },
6905
6830
  hasSave: (slot) => {
6906
6831
  const { storyData, knownSaves } = get();
6907
6832
  if (!storyData) return false;
6908
- return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
6833
+ return hasOwn(knownSaves, slot ?? "");
6909
6834
  },
6910
6835
  getSaveInfo: async (slot) => {
6911
6836
  const { storyData } = get();
@@ -6920,16 +6845,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
6920
6845
  deleteSave: (slot) => {
6921
6846
  const { storyData } = get();
6922
6847
  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
- }));
6848
+ return reported(deleteSlotSave(storyData.ifid, slot).then(() => recordKnownSave(set, slot, false)), "spindle: failed to delete save");
6933
6849
  },
6934
6850
  exportSave: async (slot) => {
6935
6851
  const { storyData } = get();
@@ -6941,40 +6857,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
6941
6857
  if (!storyData) throw new Error("spindle: Story is not initialized.");
6942
6858
  if (!isSaveExport(data)) throw new Error("Invalid save file format");
6943
6859
  const info = await importSlotSave(data, storyData.ifid, slot);
6944
- set((state) => {
6945
- state.knownSaves = {
6946
- ...state.knownSaves,
6947
- [slot ?? ""]: true
6948
- };
6949
- });
6860
+ recordKnownSave(set, slot, true);
6950
6861
  return info;
6951
6862
  },
6952
6863
  clearGameData: () => {
6953
6864
  const { storyData } = get();
6954
6865
  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
- }));
6866
+ return clearAndRestart(get, set, clearGameData(storyData.ifid), "spindle: failed to clear game data");
6977
6867
  },
6868
+ clearAllData: () => clearAndRestart(get, set, clearAllData(), "spindle: failed to clear all data"),
6978
6869
  deletePlaythrough: (playthroughId) => {
6979
6870
  const { storyData } = get();
6980
6871
  if (!storyData) return Promise.resolve();
@@ -6988,29 +6879,19 @@ var useStoryStore = create()(storyStore((set, get) => ({
6988
6879
  });
6989
6880
  if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
6990
6881
  else if (current === "") switchToLookedUpPlaythrough(deletion.then((replaced) => replaced ? replacementId : established, () => established));
6991
- return handled(deletion.then(async () => {
6882
+ return reported(deletion.then(async () => {
6992
6883
  const known = await populateKnownSaves(storyData.ifid);
6993
6884
  set((state) => {
6994
6885
  state.knownSaves = known;
6995
6886
  });
6996
- }).catch((err) => {
6997
- console.error("spindle: failed to delete playthrough", err);
6998
- throw err;
6999
- }));
6887
+ }), "spindle: failed to delete playthrough");
7000
6888
  },
7001
6889
  getSavePayload: () => {
7002
6890
  const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
7003
6891
  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
- }
6892
+ forEachRecorded(history.length, (vars, i) => {
6893
+ saveHistory.push(savedMoment(history[i], plainCopy(vars)));
6894
+ });
7014
6895
  return {
7015
6896
  passage: currentPassage,
7016
6897
  variables: plainCopy(variables),
@@ -7051,12 +6932,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7051
6932
  }
7052
6933
  variableBase = deepClone(base);
7053
6934
  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
- }));
6935
+ serializedHistory = payload.history.map((m) => savedMoment(m, serialize(m.variables)));
7060
6936
  set((state) => {
7061
6937
  state.currentPassage = payload.passage;
7062
6938
  state.navigationId++;
@@ -7075,9 +6951,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7075
6951
  });
7076
6952
  reinitTriggerState();
7077
6953
  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();
6954
+ restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
7081
6955
  persistSession(get);
7082
6956
  withoutDraws(() => emit("afterload", slot));
7083
6957
  },
@@ -7443,7 +7317,7 @@ function loadFromStorage() {
7443
7317
  /** Register a setting, starting from its default unless it has a value. */
7444
7318
  function define(name, def) {
7445
7319
  definitions.set(name, def);
7446
- if (!hasOwn$2(values, name)) values[name] = def.config.default;
7320
+ if (!hasOwn(values, name)) values[name] = def.config.default;
7447
7321
  loadFromStorage();
7448
7322
  }
7449
7323
  /** The value of setting `name` if it has the type of `fallback`, else that. */
@@ -8857,7 +8731,7 @@ defineMacro({
8857
8731
  function liveLocalsView(getValues) {
8858
8732
  return new Proxy(createNamespace(), {
8859
8733
  get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
8860
- has: (_, key) => typeof key === "string" && hasOwn$2(getValues(), key),
8734
+ has: (_, key) => typeof key === "string" && hasOwn(getValues(), key),
8861
8735
  ownKeys: () => Reflect.ownKeys(getValues()),
8862
8736
  getOwnPropertyDescriptor: (_, key) => {
8863
8737
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);