@rohal12/spindle 0.59.0 → 0.59.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,26 @@ function create() {
32
32
  return (initializer) => bindPreact(createStore()(initializer));
33
33
  }
34
34
  //#endregion
35
+ //#region src/utils/uuid.ts
36
+ /**
37
+ * A random UUID (v4). `crypto.randomUUID` exists only in secure contexts, so
38
+ * on a plain HTTP origin the ID is built from `crypto.getRandomValues`.
39
+ */
40
+ function randomUUID() {
41
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
42
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
43
+ bytes[6] = bytes[6] & 15 | 64;
44
+ bytes[8] = bytes[8] & 63 | 128;
45
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
46
+ return [
47
+ hex.slice(0, 4).join(""),
48
+ hex.slice(4, 6).join(""),
49
+ hex.slice(6, 8).join(""),
50
+ hex.slice(8, 10).join(""),
51
+ hex.slice(10).join("")
52
+ ].join("-");
53
+ }
54
+ //#endregion
35
55
  //#region src/markup/tokens.ts
36
56
  /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
37
57
  var SIGIL_SCOPES$1 = {
@@ -13061,7 +13081,14 @@ function createIDBBackend() {
13061
13081
  for (const [index, path] of Object.entries(indexes)) store.createIndex(index, path, { unique: false });
13062
13082
  }
13063
13083
  };
13064
- req.onsuccess = () => resolve(req.result);
13084
+ req.onsuccess = () => {
13085
+ const db = req.result;
13086
+ db.onversionchange = () => {
13087
+ db.close();
13088
+ dbPromise = null;
13089
+ };
13090
+ resolve(db);
13091
+ };
13065
13092
  req.onerror = () => reject(req.error);
13066
13093
  });
13067
13094
  return dbPromise;
@@ -13080,7 +13107,15 @@ function createIDBBackend() {
13080
13107
  const rows = name === "meta";
13081
13108
  /** Run one request in a transaction of its own. */
13082
13109
  const request = async (mode, make) => {
13083
- return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
13110
+ const tx = (await openDB()).transaction(name, mode);
13111
+ const result = idbReq(make(tx.objectStore(name)));
13112
+ if (mode === "readonly") return result;
13113
+ const committed = new Promise((resolve, reject) => {
13114
+ tx.oncomplete = () => resolve();
13115
+ tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
13116
+ });
13117
+ const [value] = await Promise.all([result, committed]);
13118
+ return value;
13084
13119
  };
13085
13120
  return {
13086
13121
  async get(key) {
@@ -13109,6 +13144,7 @@ function createIDBBackend() {
13109
13144
  const req = indexedDB.deleteDatabase(IDB_DB_NAME);
13110
13145
  req.onsuccess = () => resolve();
13111
13146
  req.onerror = () => reject(req.error);
13147
+ req.onblocked = () => reject(/* @__PURE__ */ new Error("Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again."));
13112
13148
  });
13113
13149
  });
13114
13150
  }
@@ -13319,15 +13355,22 @@ var ERROR_HIDDEN_KEYS = [
13319
13355
  "errors"
13320
13356
  ];
13321
13357
  function deepClone(value, options = {}) {
13322
- const seen = /* @__PURE__ */ new Map();
13358
+ const seen = options.seen ?? /* @__PURE__ */ new Map();
13323
13359
  /** Record `copy` as the copy of `obj` before its contents are copied. */
13324
13360
  const keep = (obj, copy) => {
13325
13361
  seen.set(obj, copy);
13326
13362
  return copy;
13327
13363
  };
13328
- /** Copy the own (enumerable) keys of `obj` into `copy`. */
13364
+ /**
13365
+ * Copy the own (enumerable) keys of `obj` into `copy`, symbol keys too: a
13366
+ * save refuses them, which a copy must not hide.
13367
+ */
13329
13368
  function copyKeys(obj, copy) {
13330
13369
  for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
13370
+ for (const sym of Object.getOwnPropertySymbols(obj)) {
13371
+ if (!Object.prototype.propertyIsEnumerable.call(obj, sym)) continue;
13372
+ copy[sym] = clone(obj[sym]);
13373
+ }
13331
13374
  return copy;
13332
13375
  }
13333
13376
  function cloneBuiltin(val) {
@@ -13410,65 +13453,83 @@ function loosePairs() {
13410
13453
  /**
13411
13454
  * Pairs that also require the same reference structure: each object of one
13412
13455
  * value pairs with exactly one of the other, so shared references and
13413
- * cycles are shared at the same places.
13456
+ * cycles are shared at the same places. `ab` and `ba` hold the pairs (and
13457
+ * may hold some already); those made are listed in `added`.
13414
13458
  */
13415
- function strictPairs() {
13416
- const ab = /* @__PURE__ */ new Map();
13417
- const ba = /* @__PURE__ */ new Map();
13418
- const pairs = (a, b) => {
13459
+ function strictPairs(ab = /* @__PURE__ */ new Map(), ba = /* @__PURE__ */ new Map()) {
13460
+ const added = [];
13461
+ const pairs = ((a, b) => {
13419
13462
  const known = ab.get(a);
13420
13463
  if (known !== void 0) return known === b;
13421
13464
  if (ba.has(b)) return false;
13422
13465
  ab.set(a, b);
13423
13466
  ba.set(b, a);
13424
- };
13467
+ added.push([a, b]);
13468
+ });
13425
13469
  pairs.strict = true;
13470
+ pairs.added = added;
13426
13471
  return pairs;
13427
13472
  }
13428
13473
  /**
13429
- * Whether `a` and `b` are equal as deepEqual() has it and also share
13430
- * references at the same places (a save of one loads as the other).
13431
- */
13432
- function deepEqualStrict(a, b) {
13433
- return equal(a, b, strictPairs());
13434
- }
13435
- /**
13436
13474
  * `curr` with each part that equals (see deepEqualStrict) the part of
13437
13475
  * `prev` at the same place replaced by `prev`'s: the history moments of a
13438
13476
  * save then share what did not change between them, which a save stores
13439
13477
  * once. Containers on the way to a change are copied; neither value is
13440
- * changed. Shared references and cycles within `curr` stay so.
13478
+ * changed. The result has the reference structure of `curr`: what `curr`
13479
+ * shares or cycles through stays so, and what it does not share stays
13480
+ * apart, whichever parts are reused.
13441
13481
  */
13442
13482
  function shareEqual(prev, curr) {
13483
+ if (!isObjectValue(prev) || !isObjectValue(curr)) return curr;
13484
+ /** The object of the result standing for each object of `curr` met. */
13443
13485
  const done = /* @__PURE__ */ new Map();
13444
- function share(p, c) {
13445
- if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
13446
- if (done.has(c)) return done.get(c);
13447
- if (deepEqualStrict(p, c)) {
13448
- done.set(c, p);
13449
- return p;
13486
+ /** The object of `curr` each object of `prev` in the result stands for. */
13487
+ const used = /* @__PURE__ */ new Map();
13488
+ /** `value` for the result: copied, as far as it is not met already. */
13489
+ const fresh = (value) => isObjectValue(value) ? deepClone(value, {
13490
+ keepUnregistered: true,
13491
+ seen: done
13492
+ }) : value;
13493
+ /**
13494
+ * Whether `p` and `c` are equal and so can stand for each other, which
13495
+ * needs each object in them to stand for one object only, in the whole
13496
+ * result. Then every pair of objects they hold is placed in the result.
13497
+ */
13498
+ function reuse(p, c) {
13499
+ const pairs = strictPairs(used, done);
13500
+ if (equal(p, c, pairs)) return true;
13501
+ for (const [a, b] of pairs.added) {
13502
+ used.delete(a);
13503
+ done.delete(b);
13450
13504
  }
13505
+ return false;
13506
+ }
13507
+ function share(p, c) {
13508
+ if (!isObjectValue(c)) return c;
13509
+ const placed = done.get(c);
13510
+ if (placed !== void 0) return placed;
13511
+ if (!isObjectValue(p)) return fresh(c);
13512
+ if (reuse(p, c)) return p;
13451
13513
  if (c instanceof Map && p instanceof Map) {
13452
13514
  const out = /* @__PURE__ */ new Map();
13453
13515
  done.set(c, out);
13454
- for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
13516
+ for (const [k, v] of c) out.set(fresh(k), share(p.get(k), v));
13455
13517
  return out;
13456
13518
  }
13457
13519
  if (Array.isArray(c) && Array.isArray(p)) {
13458
13520
  const out = new Array(c.length);
13459
13521
  done.set(c, out);
13460
- for (let i = 0; i < c.length; i++) if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
13522
+ for (let i = 0; i < c.length; i++) if (i in c) out[i] = share(p[i], c[i]);
13461
13523
  return out;
13462
13524
  }
13463
13525
  if (mergesWith(p, c, false)) {
13464
13526
  const out = Object.create(Object.getPrototypeOf(c));
13465
13527
  done.set(c, out);
13466
13528
  const cr = c;
13467
- for (const key of Object.keys(cr)) setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
13529
+ for (const key of Object.keys(cr)) setOwn(out, key, share(hasOwn(p, key) ? p[key] : void 0, cr[key]));
13468
13530
  return out;
13469
13531
  }
13470
- done.set(c, c);
13471
- return c;
13532
+ return fresh(c);
13472
13533
  }
13473
13534
  return share(prev, curr);
13474
13535
  }
@@ -13484,7 +13545,11 @@ function equalBytes(a, b) {
13484
13545
  function equalBuiltin(a, b, assumed) {
13485
13546
  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
13486
13547
  if (a instanceof RegExp) return String(a) === String(b);
13487
- if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) return equalBytes(a, b);
13548
+ if (ArrayBuffer.isView(a)) {
13549
+ const v = b;
13550
+ return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
13551
+ }
13552
+ if (a instanceof ArrayBuffer) return equalBytes(a, b);
13488
13553
  if (a instanceof URL || a instanceof URLSearchParams) return String(a) === String(b);
13489
13554
  if (isBoxed(a)) return Object.is(a.valueOf(), b.valueOf());
13490
13555
  if (isTemporal(a)) return String(a) === String(b);
@@ -13621,6 +13686,91 @@ function diffPaths(before, after) {
13621
13686
  })(before, after, []);
13622
13687
  return changes;
13623
13688
  }
13689
+ var pathKey = (path) => JSON.stringify(path);
13690
+ /** Whether `path`, or one of its ancestors, is one of `keys`. */
13691
+ function underChange(path, keys) {
13692
+ for (let n = 1; n <= path.length; n++) if (keys.has(pathKey(path.slice(0, n)))) return true;
13693
+ return false;
13694
+ }
13695
+ /**
13696
+ * Where each object (or array, or other value) of `root` first appears, in
13697
+ * depth-first order. Plain objects are entered; arrays and other values are
13698
+ * leaves. A path at or below one of `skip` is left out.
13699
+ */
13700
+ function firstPaths(root, skip = /* @__PURE__ */ new Set()) {
13701
+ const found = /* @__PURE__ */ new Map();
13702
+ (function walk(node, path) {
13703
+ for (const key of Object.keys(node)) {
13704
+ const value = node[key];
13705
+ if (!isObjectValue(value) || found.has(value)) continue;
13706
+ const at = [...path, key];
13707
+ if (skip.has(pathKey(at))) continue;
13708
+ found.set(value, at);
13709
+ if (isMergeable(value)) walk(value, at);
13710
+ }
13711
+ })(root, []);
13712
+ return found;
13713
+ }
13714
+ /**
13715
+ * The paths whose object is another one in the references of `after` than in
13716
+ * `before`, as to where its first appearance is: `$a = $b` makes `a` another
13717
+ * name for the object of `b` even where both hold equal content, and a
13718
+ * `diffPaths` over the content finds nothing. Only paths both hold an object
13719
+ * at are reported, parents before children; each is a write of the object
13720
+ * `after` holds.
13721
+ */
13722
+ function aliasChanges(before, after) {
13723
+ const earlier = firstPaths(before);
13724
+ const later = firstPaths(after);
13725
+ const changes = [];
13726
+ (function walk(b, a, path) {
13727
+ for (const key of Object.keys(a)) {
13728
+ if (!hasOwn(b, key)) continue;
13729
+ const x = b[key];
13730
+ const y = a[key];
13731
+ if (!isObjectValue(x) || !isObjectValue(y)) continue;
13732
+ const at = [...path, key];
13733
+ const wasAt = earlier.get(x);
13734
+ const isAt = later.get(y);
13735
+ const k = pathKey(at);
13736
+ if (pathKey(wasAt) !== pathKey(isAt)) changes.push({
13737
+ path: at,
13738
+ deleted: false,
13739
+ value: y,
13740
+ alias: true
13741
+ });
13742
+ if (isMergeable(x) && mergesWith(x, y, false) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
13743
+ }
13744
+ })(before, after, []);
13745
+ return changes;
13746
+ }
13747
+ /**
13748
+ * Where the objects of `source` are, other than at or below the paths of
13749
+ * `changes` (which write something new there), for `existingObjects`.
13750
+ */
13751
+ function locateObjects(source, changes) {
13752
+ return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
13753
+ }
13754
+ /**
13755
+ * A map for `deepClone`'s `seen`: an object of the source `paths` locate
13756
+ * (see locateObjects) is copied as the object at the same path in `target`,
13757
+ * so a value that refers to an object the store holds refers to it again
13758
+ * after the copy, not to a copy of it. Objects are looked up in `target`
13759
+ * when met, not up front.
13760
+ */
13761
+ function existingObjects(paths, target) {
13762
+ return new class extends Map {
13763
+ has(obj) {
13764
+ if (super.has(obj)) return true;
13765
+ const path = paths.get(obj);
13766
+ if (!path) return false;
13767
+ const found = getByPath(target, path);
13768
+ if (!(isObjectValue(found) && (Array.isArray(found) ? Array.isArray(obj) : Object.getPrototypeOf(found) === Object.getPrototypeOf(obj)))) return false;
13769
+ super.set(obj, found);
13770
+ return true;
13771
+ }
13772
+ }();
13773
+ }
13624
13774
  /** Whether `target` already holds what `change` would write. */
13625
13775
  function isApplied(target, change) {
13626
13776
  const parent = getByPath(target, change.path.slice(0, -1));
@@ -13765,7 +13915,7 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
13765
13915
  * saves issued before this resolves are tagged with it).
13766
13916
  */
13767
13917
  var startNewPlaythrough = queued(startNewPlaythroughNow);
13768
- async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
13918
+ async function startNewPlaythroughNow(ifid, id = randomUUID()) {
13769
13919
  const backend = await getBackend();
13770
13920
  const num = await nextPlaythroughNumber(ifid);
13771
13921
  const record = {
@@ -13889,7 +14039,7 @@ var createSave = queued(createSaveNow);
13889
14039
  async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
13890
14040
  const now = (/* @__PURE__ */ new Date()).toISOString();
13891
14041
  const record = storedRecord({
13892
- id: crypto.randomUUID(),
14042
+ id: randomUUID(),
13893
14043
  ifid,
13894
14044
  playthroughId,
13895
14045
  createdAt: now,
@@ -14215,7 +14365,7 @@ async function prepareImport(data, ifid) {
14215
14365
  checkSaveExport(data);
14216
14366
  if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
14217
14367
  const record = deepClone(data.save);
14218
- record.meta.id = crypto.randomUUID();
14368
+ record.meta.id = randomUUID();
14219
14369
  record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
14220
14370
  record.meta.ifid = ifid;
14221
14371
  await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
@@ -14405,6 +14555,32 @@ function readState() {
14405
14555
  }
14406
14556
  var cloneValue = (value) => deepClone(value, { keepUnregistered: true });
14407
14557
  /**
14558
+ * A cloner of values written into `target`. The references among the values
14559
+ * it copies are kept (`$b = $a` writes one array twice: the two paths get
14560
+ * one copy), and, given where the objects of the source are (see
14561
+ * locateObjects), an object that is one the target holds already is that
14562
+ * one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
14563
+ */
14564
+ function clonerFor(target, paths) {
14565
+ const seen = paths ? existingObjects(paths, target) : /* @__PURE__ */ new Map();
14566
+ return (value) => deepClone(value, {
14567
+ keepUnregistered: true,
14568
+ seen
14569
+ });
14570
+ }
14571
+ /** One cloner per target copy, made when first used. */
14572
+ var clonersFor = (paths) => {
14573
+ const made = /* @__PURE__ */ new Map();
14574
+ return (target) => {
14575
+ let cloner = made.get(target);
14576
+ if (!cloner) {
14577
+ cloner = clonerFor(target, paths?.(target));
14578
+ made.set(target, cloner);
14579
+ }
14580
+ return cloner;
14581
+ };
14582
+ };
14583
+ /**
14408
14584
  * A frozen copy of a value read from a working copy, matching what reads
14409
14585
  * from the (frozen) store return: writing to it fails instead of changing
14410
14586
  * the code's pending state behind its back.
@@ -14421,12 +14597,12 @@ function frozenCopy(value) {
14421
14597
  * path (the code replaced or deleted an intermediate object), its root is
14422
14598
  * replaced with the store's root as written.
14423
14599
  */
14424
- function mirror(draft, ns, change, scopes = activeScopes) {
14600
+ function mirror(draft, ns, change, scopes = activeScopes, cloner = clonerOf) {
14425
14601
  const root = change.path[0];
14426
14602
  for (const scope of scopes) for (const copy of [scope.work[ns], scope.base[ns]]) try {
14427
14603
  applyChange(copy, change.deleted ? change : {
14428
14604
  ...change,
14429
- value: cloneValue(change.value)
14605
+ value: cloner(copy)(change.value)
14430
14606
  });
14431
14607
  } catch {
14432
14608
  const stored = draft[ns][root];
@@ -14434,6 +14610,8 @@ function mirror(draft, ns, change, scopes = activeScopes) {
14434
14610
  else delete copy[root];
14435
14611
  }
14436
14612
  }
14613
+ /** A cloner of a copy of its own (no references shared with others). */
14614
+ var clonerOf = (target) => clonerFor(target);
14437
14615
  var NAMESPACE_KEYS$1 = new Set(NAMESPACES);
14438
14616
  /**
14439
14617
  * Runs store updates on the running code's working copies. They stay the
@@ -14533,11 +14711,12 @@ function routeStoreUpdate(recipe) {
14533
14711
  const own = patches.filter((p) => p.path[0] === ns);
14534
14712
  return [ns, own.length ? writtenPaths(view[ns], next[ns], own) : []];
14535
14713
  });
14536
- const owned = (change) => change.deleted ? change : {
14714
+ const cloners = clonersFor();
14715
+ const owned = (change, target) => change.deleted ? change : {
14537
14716
  ...change,
14538
- value: cloneValue(change.value)
14717
+ value: cloners(target)(change.value)
14539
14718
  };
14540
- for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change));
14719
+ for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change, inner.work[ns]));
14541
14720
  return (draft) => {
14542
14721
  const target = draft;
14543
14722
  for (const key of Object.keys(next)) {
@@ -14548,12 +14727,12 @@ function routeStoreUpdate(recipe) {
14548
14727
  for (const [ns, list] of changes) for (const change of list) {
14549
14728
  const root = change.path[0];
14550
14729
  try {
14551
- applyChange(namespaces[ns], owned(change));
14730
+ applyChange(namespaces[ns], owned(change, namespaces[ns]));
14552
14731
  } catch {
14553
14732
  if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
14554
14733
  else delete namespaces[ns][root];
14555
14734
  }
14556
- mirror(namespaces, ns, change);
14735
+ mirror(namespaces, ns, change, activeScopes, cloners);
14557
14736
  }
14558
14737
  };
14559
14738
  }
@@ -14581,7 +14760,7 @@ function commitScopes(commits) {
14581
14760
  const all = commits.map(({ scope, keepRunning }) => ({
14582
14761
  scope,
14583
14762
  keepRunning,
14584
- changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
14763
+ changes: NAMESPACES.map((ns) => [ns, changesOf(scope.base[ns], scope.work[ns])])
14585
14764
  }));
14586
14765
  const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
14587
14766
  if (changed.length === 0) return;
@@ -14593,29 +14772,53 @@ function commitScopes(commits) {
14593
14772
  committing = false;
14594
14773
  }
14595
14774
  function commitRecipe(draft) {
14596
- all.forEach(({ scope, keepRunning, changes }, i) => {
14597
- const own = (value) => keepRunning ? cloneValue(value) : value;
14775
+ all.forEach(({ scope, changes }, i) => {
14776
+ const paths = new Map(changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]));
14777
+ const own = new Map(changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]));
14778
+ const enclosing = all.slice(0, i).map((c) => c.scope);
14779
+ const enclosingCloners = new Map(changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns))]));
14598
14780
  for (const [ns, list] of changes) {
14599
14781
  const replaced = /* @__PURE__ */ new Set();
14600
14782
  for (const change of list) {
14601
14783
  const root = change.path[0];
14602
- if (replaced.has(root) || isApplied(draft[ns], change)) continue;
14784
+ if (replaced.has(root)) continue;
14603
14785
  try {
14604
- applyChange(draft[ns], change.deleted ? change : {
14786
+ if (change.deleted) {
14787
+ if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
14788
+ continue;
14789
+ }
14790
+ const value = change.alias ? own.get(ns)(change.value) : void 0;
14791
+ if (change.alias ? Object.is(getByPath(draft[ns], change.path), value) : isApplied(draft[ns], change)) continue;
14792
+ applyChange(draft[ns], {
14605
14793
  ...change,
14606
- value: own(change.value)
14794
+ value: change.alias ? value : own.get(ns)(change.value)
14607
14795
  });
14608
14796
  } catch {
14609
- draft[ns][root] = own(scope.work[ns][root]);
14797
+ draft[ns][root] = own.get(ns)(scope.work[ns][root]);
14610
14798
  replaced.add(root);
14611
14799
  }
14612
14800
  }
14613
- const enclosing = all.slice(0, i).map((c) => c.scope);
14614
- for (const change of list) mirror(draft, ns, change, enclosing);
14801
+ for (const change of list) mirror(draft, ns, change, enclosing, enclosingCloners.get(ns));
14615
14802
  }
14616
14803
  });
14617
14804
  }
14618
14805
  }
14806
+ /**
14807
+ * The changes of the code to a namespace: its property paths that differ
14808
+ * (see diffPaths), and those that hold equal content but another object
14809
+ * than they did (see aliasChanges), those first.
14810
+ */
14811
+ function changesOf(base, work) {
14812
+ const written = diffPaths(base, work);
14813
+ const keys = new Set(written.map((c) => pathKey(c.path)));
14814
+ const aliases = [];
14815
+ for (const change of aliasChanges(base, work)) {
14816
+ if (underChange(change.path, keys)) continue;
14817
+ keys.add(pathKey(change.path));
14818
+ aliases.push(change);
14819
+ }
14820
+ return [...aliases, ...written];
14821
+ }
14619
14822
  /** Every running mutation, to commit with its code going on. */
14620
14823
  var running = () => activeScopes.map((scope) => ({
14621
14824
  scope,
@@ -14851,15 +15054,33 @@ function registerDialogHost(callbacks) {
14851
15054
  if (dialogHostCallbacks === callbacks) dialogHostCallbacks = null;
14852
15055
  };
14853
15056
  }
15057
+ /**
15058
+ * The dialogs on display, bottom first, whoever opened them: the trigger
15059
+ * host, `{dialog}` or a menubar button (each PassageDialog registers itself
15060
+ * while mounted), as the function that closes each.
15061
+ */
15062
+ var openDialogs = [];
15063
+ /** Register a displayed dialog; returns the function that unregisters it. */
15064
+ function registerOpenDialog(close) {
15065
+ openDialogs.push(close);
15066
+ return () => {
15067
+ const i = openDialogs.lastIndexOf(close);
15068
+ if (i >= 0) openDialogs.splice(i, 1);
15069
+ };
15070
+ }
15071
+ /** Close the topmost dialog. */
14854
15072
  function closeCurrentDialog() {
14855
- dialogHostCallbacks?.close();
15073
+ const top = openDialogs[openDialogs.length - 1];
15074
+ if (top) top();
15075
+ else dialogHostCallbacks?.close();
14856
15076
  }
14857
15077
  function closeAllOpenDialogs() {
14858
15078
  clearDialogQueue();
15079
+ for (const close of [...openDialogs].reverse()) close();
14859
15080
  dialogHostCallbacks?.closeAll();
14860
15081
  }
14861
15082
  function isDialogShowing() {
14862
- return dialogHostCallbacks?.isOpen() ?? false;
15083
+ return openDialogs.length > 0 || (dialogHostCallbacks?.isOpen() ?? false);
14863
15084
  }
14864
15085
  //#endregion
14865
15086
  //#region src/runtime-errors.ts
@@ -14942,7 +15163,10 @@ var lastNavigationVars = createNamespace();
14942
15163
  * object) or deleted. A recorded value is a deep copy, so that changing a
14943
15164
  * live value in place (a class instance is not frozen) cannot change
14944
15165
  * history; instances of unregistered classes are kept, as the store keeps
14945
- * them (a save then refuses them).
15166
+ * them (a save then refuses them). The copies are made together, so what
15167
+ * variables share (an object, a cycle) stays shared in them, and a variable
15168
+ * left as it is that shares with a replaced one is recorded again, to share
15169
+ * with its copy.
14946
15170
  */
14947
15171
  function computeVarPatches(prev, curr) {
14948
15172
  const forward = [];
@@ -14950,11 +15174,33 @@ function computeVarPatches(prev, curr) {
14950
15174
  key,
14951
15175
  deleted: true
14952
15176
  });
14953
- for (const key of Object.keys(curr)) if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) forward.push({
15177
+ const seen = /* @__PURE__ */ new Map();
15178
+ const record = (key) => forward.push({
14954
15179
  key,
14955
15180
  deleted: false,
14956
- value: deepClone(curr[key], { keepUnregistered: true })
15181
+ value: deepClone(curr[key], {
15182
+ keepUnregistered: true,
15183
+ seen
15184
+ })
14957
15185
  });
15186
+ const unchanged = [];
15187
+ for (const key of Object.keys(curr)) if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
15188
+ else if (typeof curr[key] === "object" && curr[key] !== null) unchanged.push(key);
15189
+ for (let found = seen.size > 0; found;) {
15190
+ found = false;
15191
+ for (const key of unchanged) {
15192
+ const probe = /* @__PURE__ */ new Map();
15193
+ deepClone(curr[key], {
15194
+ keepUnregistered: true,
15195
+ seen: probe
15196
+ });
15197
+ if (![...probe.keys()].some((object) => seen.has(object))) continue;
15198
+ record(key);
15199
+ unchanged.splice(unchanged.indexOf(key), 1);
15200
+ found = true;
15201
+ break;
15202
+ }
15203
+ }
14958
15204
  return { forward };
14959
15205
  }
14960
15206
  /**
@@ -15273,7 +15519,7 @@ function switchToPlaythrough(id, stored) {
15273
15519
  }
15274
15520
  /** Move the running game to a new playthrough at once (see restart). */
15275
15521
  function switchToNewPlaythrough(ifid) {
15276
- const id = crypto.randomUUID();
15522
+ const id = randomUUID();
15277
15523
  switchToPlaythrough(id, startNewPlaythrough(ifid, id).catch((err) => {
15278
15524
  console.error("spindle: failed to start new playthrough", err);
15279
15525
  }));
@@ -15786,7 +16032,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
15786
16032
  const ifid = storyData.ifid;
15787
16033
  const current = knownPlaythroughId();
15788
16034
  const established = playthroughSetup;
15789
- const replacementId = crypto.randomUUID();
16035
+ const replacementId = randomUUID();
15790
16036
  const deletion = deletePlaythroughData(ifid, playthroughId, {
15791
16037
  current: current || established,
15792
16038
  id: replacementId
@@ -16022,6 +16268,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16022
16268
  }, [markup, nobr]);
16023
16269
  const panelRef = useRef(null);
16024
16270
  useModalFocus(panelRef, ".dialog-body", dismissible, stableOnClose);
16271
+ useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
16025
16272
  useLayoutEffect(() => {
16026
16273
  if (panelRef.current) emitFromRender("dialogrender", passageName ?? "", panelRef.current);
16027
16274
  }, []);
@@ -16619,7 +16866,7 @@ function unset(param) {
16619
16866
  return param.type === "options" ? {} : void 0;
16620
16867
  }
16621
16868
  var WORD_RE = /\w+/y;
16622
- var DIGITS_RE = /\d+/y;
16869
+ var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
16623
16870
  var WORD_OR_PUNCT_RE = /\S+/y;
16624
16871
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
16625
16872
  function matchAt(re, src, pos) {
@@ -16628,7 +16875,8 @@ function matchAt(re, src, pos) {
16628
16875
  }
16629
16876
  /**
16630
16877
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
16631
- * quoted string (with or without whitespace before it) or digit run after
16878
+ * quoted string (with or without whitespace before it) or number (signed,
16879
+ * fractional) after
16632
16880
  * it as its value; keywords that aren't declared, and their values, are
16633
16881
  * skipped. A keyword of a `string` parameter must be followed by a quoted
16634
16882
  * string.
@@ -16647,7 +16895,7 @@ function readOptions(params, src, { at, spans }) {
16647
16895
  let j = i;
16648
16896
  while (j < src.length && isWhitespace(src[j])) j++;
16649
16897
  const quoted = readQuoted(src, j);
16650
- const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
16898
+ const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
16651
16899
  if (quoted) {
16652
16900
  val = quoted.value;
16653
16901
  i = quoted.end;
@@ -17364,10 +17612,6 @@ function parameterLookup(macros) {
17364
17612
  }
17365
17613
  /** What a `string` or `text` argument holds when its parameter doesn't say. */
17366
17614
  var holdsByDefault = (interpolate) => interpolate ? "markup" : "text";
17367
- /** Whether a parameter in `params` (options too) holds code in a string. */
17368
- function holdsCode(params) {
17369
- return params.some((p) => p.holds === "expression" || p.holds === "statements" || !!p.parameters && holdsCode(p.parameters));
17370
- }
17371
17615
  /** The goal of the code an argument of this type holds, if it is code. */
17372
17616
  function codeGoal(param) {
17373
17617
  if (param.type === "expression" || param.type === "passage") return "expression";
@@ -17390,15 +17634,22 @@ function* codeAndText(src, tokens, parametersOf) {
17390
17634
  const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
17391
17635
  for (let t = 0; t < tokens.length; t++) {
17392
17636
  const token = tokens[t];
17393
- if (token.type === "link") yield {
17394
- kind: "passage",
17395
- name: token.target,
17396
- offset: token.targetStart,
17397
- length: token.targetEnd - token.targetStart,
17398
- macro: "link",
17399
- label: src.slice(token.start, token.end)
17400
- };
17401
- else if (token.type === "expression") yield {
17637
+ if (token.type === "link") {
17638
+ yield {
17639
+ kind: "passage",
17640
+ name: token.target,
17641
+ offset: token.targetStart,
17642
+ length: token.targetEnd - token.targetStart,
17643
+ macro: "link",
17644
+ label: src.slice(token.start, token.end)
17645
+ };
17646
+ if (token.display.includes("{")) yield {
17647
+ kind: "text",
17648
+ text: token.display,
17649
+ offset: locate(src, token.display, token.start),
17650
+ where: "In the label of a link: "
17651
+ };
17652
+ } else if (token.type === "expression") yield {
17402
17653
  kind: "code",
17403
17654
  code: token.expression,
17404
17655
  offset: token.end - 1 - token.expression.length,
@@ -17564,7 +17815,7 @@ function* argPieces(args, offset, params, macro, label) {
17564
17815
  };
17565
17816
  }
17566
17817
  }
17567
- yield* visit(params, values);
17818
+ yield* [...visit(params, values)].sort((a, b) => a.offset - b.offset);
17568
17819
  }
17569
17820
  /**
17570
17821
  * Index of `part` in `text` from `from` on, else anywhere, else `from`: the
@@ -17819,7 +18070,6 @@ function declarationRegex(sigil) {
17819
18070
  var VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
17820
18071
  /** Quoted first argument of an input macro naming a story variable. */
17821
18072
  var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
17822
- var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
17823
18073
  var VALID_VAR_TYPES = new Set([
17824
18074
  "number",
17825
18075
  "string",
@@ -17882,30 +18132,14 @@ function parseStoryVariables(content, sigil = "$") {
17882
18132
  return schema;
17883
18133
  }
17884
18134
  /**
17885
- * Extract for-loop local variable names from passage content.
17886
- * `{for @item of ...}` → "item"
17887
- * `{for @index, @item of ...}` → "index", "item"
17888
- */
17889
- function extractForLocals(content) {
17890
- const locals = /* @__PURE__ */ new Set();
17891
- let match;
17892
- FOR_LOCAL_RE.lastIndex = 0;
17893
- while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
17894
- locals.add(match[1]);
17895
- if (match[2]) locals.add(match[2]);
17896
- }
17897
- return locals;
17898
- }
17899
- /**
17900
18135
  * Validate a single variable reference path (e.g. "player.health") against
17901
18136
  * the schema. Returns an error message or null if valid.
17902
18137
  */
17903
- function validateRef(ref, schema, forLocals) {
18138
+ function validateRef(ref, schema) {
17904
18139
  const parts = ref.split(".");
17905
18140
  const rootName = parts[0];
17906
18141
  const nameError = variableNameError(rootName, "$" + rootName);
17907
18142
  if (nameError) return nameError;
17908
- if (forLocals.has(rootName)) return null;
17909
18143
  const rootSchema = schema.get(rootName);
17910
18144
  if (!rootSchema) return `Undeclared variable: $${ref}`;
17911
18145
  let current = rootSchema;
@@ -17961,8 +18195,9 @@ function scanInterpolations(text, onRef, storeVarMacros = NO_STORE_VAR_MACROS) {
17961
18195
  * Report `$var` references in JavaScript code, lexed as the expression
17962
18196
  * engine lexes it (`lexJs`): references in code, but none inside string or
17963
18197
  * regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
17964
- * The text of string and template literals is scanned for the markup that
17965
- * labels evaluate (`{$…}`); the `${…}` parts of template literals are code.
18198
+ * The text of string and template literals is not scanned: a string in code
18199
+ * is literal text unless a macro argument that holds markup is read as such
18200
+ * (see scanArgs); the `${…}` parts of template literals are code.
17966
18201
  */
17967
18202
  function scanCode(code, onRef, goal = "expression") {
17968
18203
  const parsed = parseOrError(code, goal);
@@ -17975,37 +18210,17 @@ function scanCode(code, onRef, goal = "expression") {
17975
18210
  VAR_PATH_RE.lastIndex = ref.start;
17976
18211
  onRef(VAR_PATH_RE.exec(code)[1]);
17977
18212
  }
17978
- for (const text of parsed.strings) scanInterpolations(text, onRef);
17979
18213
  }
17980
18214
  /** `scanCode` for code acorn can't parse: lexed leniently. */
17981
18215
  function scanCodeLeniently(code, onRef, goal) {
17982
- /** Open template literals: their text so far, null in an interpolation. */
17983
- const templates = [];
17984
18216
  lexJs(code, {
17985
18217
  variable(sigil, _name, index) {
17986
18218
  if (sigil !== "$") return;
17987
18219
  VAR_PATH_RE.lastIndex = index;
17988
18220
  onRef(VAR_PATH_RE.exec(code)[1]);
17989
18221
  },
17990
- literal(text, _index, nesting) {
17991
- const template = templates[templates.length - 1];
17992
- if (template?.nesting === nesting) if (template.text === null) template.text = "";
17993
- else if (text === "`" || text === "${") {
17994
- scanInterpolations(template.text, onRef);
17995
- if (text === "`") templates.pop();
17996
- else template.text = null;
17997
- } else template.text += text;
17998
- else if (text === "`") templates.push({
17999
- nesting,
18000
- text: ""
18001
- });
18002
- else if (text[0] === "\"" || text[0] === "'") {
18003
- const { closed } = scanStringLiteral(text, 0);
18004
- scanInterpolations(text.slice(1, closed ? -1 : void 0), onRef);
18005
- }
18006
- }
18222
+ literal() {}
18007
18223
  }, goal);
18008
- for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
18009
18224
  }
18010
18225
  /**
18011
18226
  * Report the `$var` references a passage evaluates at runtime: `{$var}`
@@ -18017,14 +18232,23 @@ function collectPassageRefs(content, storeVarMacros, onRef, parametersOf) {
18017
18232
  withParseCache(() => collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef, parametersOf));
18018
18233
  }
18019
18234
  /**
18020
- * Report the references in the code that a macro's quoted arguments hold
18021
- * (see ParameterDef.holds), the condition and `run` action of `{watch}`:
18022
- * the argument scan sees only the strings. Only the macros that declare
18023
- * code in a string have their arguments read.
18235
+ * Report the references in the arguments of a macro by what its parameters
18236
+ * say each holds (see ParameterDef.holds): code is scanned as code and
18237
+ * markup as markup, but literal text (a placeholder, a passage name) holds
18238
+ * none. A macro without declared parameters, or arguments that do not
18239
+ * parse, are scanned as code.
18024
18240
  */
18025
- function scanStringCode(token, params, onRef) {
18026
- if (!params || !token.rawArgs || !holdsCode(params)) return;
18027
- for (const piece of argPieces(token.rawArgs, 0, params, token.name, "")) if (piece.kind === "code" && piece.inString) scanCode(piece.code, onRef, piece.goal);
18241
+ function scanArgs(token, params, onRef) {
18242
+ if (!token.rawArgs) return;
18243
+ if (params) {
18244
+ const pieces = [...argPieces(token.rawArgs, 0, params, token.name, "")];
18245
+ if (!pieces.some((piece) => piece.kind === "argument-error")) {
18246
+ for (const piece of pieces) if (piece.kind === "code") scanCode(piece.code, onRef, piece.goal);
18247
+ else if (piece.kind === "text") scanInterpolations(piece.text, onRef);
18248
+ return;
18249
+ }
18250
+ }
18251
+ scanCode(token.rawArgs, onRef);
18028
18252
  }
18029
18253
  /** Report the `$var` references in the tokens of `content`. */
18030
18254
  function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf = () => void 0) {
@@ -18032,22 +18256,25 @@ function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf =
18032
18256
  const token = tokens[t];
18033
18257
  if (token.type === "variable") {
18034
18258
  if (token.scope === "variable" && token.name) onRef(token.name);
18035
- } else if (token.type === "expression") scanCode(token.expression, onRef);
18259
+ } else if (token.type === "link") scanInterpolations(token.display, onRef);
18260
+ else if (token.type === "expression") scanCode(token.expression, onRef);
18036
18261
  else if (token.type === "html") for (const [name, value] of Object.entries(token.attributes)) if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
18037
18262
  else scanInterpolations(value, onRef, storeVarMacros);
18038
18263
  else if (token.type === "macro" && !token.isClose) {
18039
- scanCode(token.rawArgs, onRef);
18040
- scanStringCode(token, parametersOf(token.name), onRef);
18264
+ scanArgs(token, parametersOf(token.name), onRef);
18265
+ if (token.className) scanInterpolations(token.className, onRef);
18266
+ if (token.id) scanInterpolations(token.id, onRef);
18041
18267
  if (storeVarMacros.has(token.name.toLowerCase())) {
18042
18268
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
18043
18269
  const quoted = QUOTED_VAR_ARG_RE.exec(first);
18044
18270
  if (quoted) onRef(quoted[1]);
18271
+ else if (!/^["'`]/.test(first)) scanCode(first, onRef);
18045
18272
  }
18046
- if (token.name === "do") {
18273
+ if (token.name.toLowerCase() === "do") {
18047
18274
  let close = t + 1;
18048
18275
  while (close < tokens.length) {
18049
18276
  const c = tokens[close];
18050
- if (c.type === "macro" && c.isClose && c.name === "do") break;
18277
+ if (c.type === "macro" && c.isClose && c.name.toLowerCase() === "do") break;
18051
18278
  close++;
18052
18279
  }
18053
18280
  if (close < tokens.length) {
@@ -18071,9 +18298,8 @@ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_M
18071
18298
  const storeVarSet = new Set(Array.from(storeVarMacros, (m) => m.toLowerCase()));
18072
18299
  for (const [name, passage] of passages) {
18073
18300
  if (NOT_MARKUP.has(name)) continue;
18074
- const forLocals = extractForLocals(passage.content);
18075
18301
  collectPassageRefs(passage.content, storeVarSet, (ref) => {
18076
- const error = validateRef(ref, schema, forLocals);
18302
+ const error = validateRef(ref, schema);
18077
18303
  if (error) errors.push(`Passage "${name}": ${error}`);
18078
18304
  }, parametersOf);
18079
18305
  }
@@ -18336,7 +18562,10 @@ defineMacro({
18336
18562
  ],
18337
18563
  render(_props, ctx) {
18338
18564
  try {
18339
- const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
18565
+ const { currentExpr, maxExpr, labelMode } = meterArgs({
18566
+ ...ctx.args,
18567
+ label: ctx.resolve?.(ctx.args.label)
18568
+ });
18340
18569
  const current = Number(ctx.evaluate(currentExpr));
18341
18570
  const max = Number(ctx.evaluate(maxExpr));
18342
18571
  const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
@@ -18737,7 +18966,7 @@ defineMacro({
18737
18966
  type: "passage"
18738
18967
  }],
18739
18968
  render({ children = [] }, ctx) {
18740
- const display = ctx.args.text ?? "";
18969
+ const display = ctx.resolve?.(ctx.args.text ?? "") ?? "";
18741
18970
  let passage = null;
18742
18971
  let failure;
18743
18972
  if (ctx.args.passage !== void 0) try {
@@ -18884,7 +19113,7 @@ defineMacro({
18884
19113
  visibleCharsRef.current = visibleChars;
18885
19114
  useEffect(() => {
18886
19115
  if (containerRef.current) setTotalChars((containerRef.current.textContent || "").length);
18887
- }, []);
19116
+ });
18888
19117
  useEffect(() => {
18889
19118
  if (totalChars === 0) return;
18890
19119
  if (visibleCharsRef.current >= totalChars) return;