@rohal12/spindle 0.52.0 → 0.52.1

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.
@@ -149,10 +149,10 @@ function equal(a, b, assumed) {
149
149
  const bo = b;
150
150
  const keys = Object.keys(ao);
151
151
  if (keys.length !== Object.keys(bo).length) return false;
152
- for (const key of keys) if (!hasOwn$2(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
152
+ for (const key of keys) if (!hasOwn$3(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
153
153
  return true;
154
154
  }
155
- var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
155
+ var hasOwn$3 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
156
156
  var CLASS_TAG = "__spindle_class__";
157
157
  var DATA_TAG = "__spindle_data__";
158
158
  /** A tagged value, as serialize() writes and deserialize() reads it. */
@@ -224,7 +224,7 @@ function serialize(value) {
224
224
  const name = registeredClassName(val);
225
225
  if (name !== void 0) return tagged(name, serKeys(val));
226
226
  const data = serKeys(val);
227
- return hasOwn$2(val, CLASS_TAG) ? tagged("__Object__", data) : data;
227
+ return hasOwn$3(val, CLASS_TAG) ? tagged("__Object__", data) : data;
228
228
  }
229
229
  return ser(value);
230
230
  }
@@ -310,7 +310,7 @@ function isDeserializable(value) {
310
310
  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]));
311
311
  case "__Set__": return Array.isArray(data.entries) && Array.from(data.entries).every(check);
312
312
  case "__Undefined__": return true;
313
- case "__Number__": return typeof data.value === "string" && hasOwn$2(SPECIAL_NUMBERS, data.value);
313
+ case "__Number__": return typeof data.value === "string" && hasOwn$3(SPECIAL_NUMBERS, data.value);
314
314
  case "__BigInt__": return typeof data.value === "string" && /^-?\d+$/.test(data.value);
315
315
  default: return checkKeys(data);
316
316
  }
@@ -2743,8 +2743,8 @@ function buildAST(tokens) {
2743
2743
  current().push(top.node);
2744
2744
  break;
2745
2745
  }
2746
- if (BRANCH_PARENT[name]) {
2747
- const expectedParent = BRANCH_PARENT[name];
2746
+ const expectedParent = Object.prototype.hasOwnProperty.call(BRANCH_PARENT, name) ? BRANCH_PARENT[name] : void 0;
2747
+ if (expectedParent) {
2748
2748
  const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
2749
2749
  if (!topNode || topNode.type !== "macro" || topNode.name !== expectedParent) throw new Error(`{${token.name}} without matching {${expectedParent}} (at character ${token.start})`);
2750
2750
  const branch = {
@@ -2860,6 +2860,8 @@ var enabled = false;
2860
2860
  var currentSeed = "";
2861
2861
  var currentPull = 0;
2862
2862
  var generator = null;
2863
+ /** Incremented whenever the generator is replaced (init, restore, reset). */
2864
+ var generation = 0;
2863
2865
  /**
2864
2866
  * Initialize the PRNG.
2865
2867
  * @param seed Optional seed string. If omitted, a random seed is generated.
@@ -2876,12 +2878,18 @@ function initPRNG(seed, useEntropy = true) {
2876
2878
  currentPull = 0;
2877
2879
  generator = mulberry32(hashSeed(resolvedSeed));
2878
2880
  enabled = true;
2881
+ generation++;
2879
2882
  }
2880
2883
  /**
2881
2884
  * Restore PRNG state from a snapshot (used on save/load).
2882
2885
  * Recreates the generator from the seed and fast-forwards to the saved pull count.
2883
2886
  */
2884
2887
  function restorePRNG(seed, pull) {
2888
+ generation++;
2889
+ moveTo(seed, pull);
2890
+ }
2891
+ /** Recreate the generator at `pull` pulls from `seed`. */
2892
+ function moveTo(seed, pull) {
2885
2893
  currentSeed = seed;
2886
2894
  const steps = Math.imul(pull % 4294967296, MULBERRY_STEP);
2887
2895
  generator = mulberry32(hashSeed(seed) + steps | 0);
@@ -2890,11 +2898,32 @@ function restorePRNG(seed, pull) {
2890
2898
  }
2891
2899
  /** Disable the PRNG (used on restart before StoryInit re-enables it). */
2892
2900
  function resetPRNG() {
2901
+ generation++;
2893
2902
  enabled = false;
2894
2903
  currentSeed = "";
2895
2904
  currentPull = 0;
2896
2905
  generator = null;
2897
2906
  }
2907
+ /**
2908
+ * Run `fn` without its draws advancing the sequence: the draws it makes are
2909
+ * taken back afterwards (also when it throws), so the next draws return the
2910
+ * same values again. Save and load event handlers run like this, so that
2911
+ * saving or loading never shifts the story's random sequence (a load replays
2912
+ * the saved passage from its entry state, without the save's handlers). A
2913
+ * generator `fn` replaced (a restart or load it started) is kept.
2914
+ */
2915
+ function withoutDraws(fn) {
2916
+ const start = {
2917
+ generation,
2918
+ seed: currentSeed,
2919
+ pull: currentPull
2920
+ };
2921
+ try {
2922
+ return fn();
2923
+ } finally {
2924
+ if (enabled && generation === start.generation && currentPull !== start.pull) moveTo(start.seed, start.pull);
2925
+ }
2926
+ }
2898
2927
  /** Returns the current PRNG state for snapshotting, or null if disabled. */
2899
2928
  function snapshotPRNG() {
2900
2929
  if (!enabled) return null;
@@ -2958,6 +2987,30 @@ var EMPTY_NAMESPACE = Object.freeze(createNamespace());
2958
2987
  /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2959
2988
  var ownValue = (ns, key) => Object.prototype.hasOwnProperty.call(ns, key) ? ns[key] : void 0;
2960
2989
  //#endregion
2990
+ //#region src/utils/counts.ts
2991
+ var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
2992
+ /** The count of `name` in `counts`: its own entry, or 0. */
2993
+ function countOf(counts, name) {
2994
+ if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
2995
+ const value = counts[name];
2996
+ return isCount(value) ? value : 0;
2997
+ }
2998
+ /**
2999
+ * Counters with no prototype holding the own entries of `source` that are
3000
+ * counts, with `name` (if given) counted once more. Immer copies the
3001
+ * counters on every write anyway, so counting into a fresh copy costs no
3002
+ * more than counting in place.
3003
+ */
3004
+ function createCounts(source, name) {
3005
+ const counts = Object.create(null);
3006
+ if (source) for (const key of Object.keys(source)) {
3007
+ const value = source[key];
3008
+ if (isCount(value)) counts[key] = value;
3009
+ }
3010
+ if (name !== void 0) counts[name] = countOf(source, name) + 1;
3011
+ return counts;
3012
+ }
3013
+ //#endregion
2961
3014
  //#region src/expression.ts
2962
3015
  var FN_CACHE_MAX = 500;
2963
3016
  var fnCache = /* @__PURE__ */ new Map();
@@ -3021,11 +3074,11 @@ var cachedRenderCounts = null;
3021
3074
  function buildExpressionFns() {
3022
3075
  const { visitCounts, renderCounts } = useStoryStore.getState();
3023
3076
  if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
3024
- const visited = (name) => visitCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
3077
+ const visited = (name) => countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
3025
3078
  const hasVisited = (name) => visited(name) > 0;
3026
3079
  const hasVisitedAny = (...names) => names.some((n) => visited(n) > 0);
3027
3080
  const hasVisitedAll = (...names) => names.every((n) => visited(n) > 0);
3028
- const rendered = (name) => renderCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
3081
+ const rendered = (name) => countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
3029
3082
  const hasRendered = (name) => rendered(name) > 0;
3030
3083
  const hasRenderedAny = (...names) => names.some((n) => rendered(n) > 0);
3031
3084
  const hasRenderedAll = (...names) => names.every((n) => rendered(n) > 0);
@@ -5150,6 +5203,10 @@ function establishPlaythrough(ifid) {
5150
5203
  * that captures the payload after them, so the capture can tell which
5151
5204
  * variables the hooks changed (the store's `beginSave`).
5152
5205
  *
5206
+ * Random numbers the hooks draw do not advance the story's PRNG (see
5207
+ * withoutDraws()): the game goes on with the sequence a load of the save
5208
+ * continues with, which replays the saved passage without the hooks.
5209
+ *
5153
5210
  * The hook and capture run synchronously, but an error they throw (a failing
5154
5211
  * hook, an unserializable payload) rejects the returned promise like a failed
5155
5212
  * write rather than escaping to the caller.
@@ -5157,9 +5214,9 @@ function establishPlaythrough(ifid) {
5157
5214
  function saveWithHooks(slot, custom, beginCapture, write) {
5158
5215
  try {
5159
5216
  const capture = beginCapture();
5160
- emit("beforesave", slot, custom);
5217
+ withoutDraws(() => emit("beforesave", slot, custom));
5161
5218
  return write(capture()).then((result) => {
5162
- emit("aftersave", slot);
5219
+ withoutDraws(() => emit("aftersave", slot));
5163
5220
  return result;
5164
5221
  });
5165
5222
  } catch (err) {
@@ -5727,7 +5784,7 @@ function initializeStory(session) {
5727
5784
  }
5728
5785
  //#endregion
5729
5786
  //#region src/utils/object-path.ts
5730
- var hasOwn$1 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5787
+ var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5731
5788
  /**
5732
5789
  * Traverse dot-path segments on an object and return the nested value.
5733
5790
  * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
@@ -5739,7 +5796,7 @@ function getByPath(obj, segments) {
5739
5796
  let current = obj;
5740
5797
  for (const seg of segments) {
5741
5798
  if (current == null || typeof current !== "object") return void 0;
5742
- if (seg in Object.prototype && !hasOwn$1(current, seg)) return void 0;
5799
+ if (seg in Object.prototype && !hasOwn$2(current, seg)) return void 0;
5743
5800
  current = current[seg];
5744
5801
  }
5745
5802
  return current;
@@ -5797,10 +5854,10 @@ function deleteByPath(root, segments) {
5797
5854
  checkSegments(segments);
5798
5855
  let holder = root;
5799
5856
  for (const seg of segments.slice(0, -1)) {
5800
- if (holder == null || typeof holder !== "object" || !hasOwn$1(holder, seg)) return;
5857
+ if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, seg)) return;
5801
5858
  holder = holder[seg];
5802
5859
  }
5803
- if (holder == null || typeof holder !== "object" || !hasOwn$1(holder, last)) return;
5860
+ if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, last)) return;
5804
5861
  const parent = walkToParent(root, segments, false);
5805
5862
  delete parent[last];
5806
5863
  }
@@ -5826,7 +5883,7 @@ function walkToParent(root, segments, createMissing) {
5826
5883
  for (let i = 0; i < segments.length - 1; i++) {
5827
5884
  const seg = segments[i];
5828
5885
  checkHolder(current, seg, segments, i);
5829
- let next = hasOwn$1(current, seg) ? current[seg] : void 0;
5886
+ let next = hasOwn$2(current, seg) ? current[seg] : void 0;
5830
5887
  if (next == null || typeof next !== "object") {
5831
5888
  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(".")}")`);
5832
5889
  next = {};
@@ -5890,7 +5947,7 @@ function frozenCopy(value) {
5890
5947
  function isMergeable(value) {
5891
5948
  return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
5892
5949
  }
5893
- var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5950
+ var hasOwn$1 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5894
5951
  /** Collect the property paths where `work` differs from `base`. */
5895
5952
  function diff(base, work, path, changes, ancestors) {
5896
5953
  if (ancestors.has(work)) return;
@@ -5898,7 +5955,7 @@ function diff(base, work, path, changes, ancestors) {
5898
5955
  for (const key of Object.keys(work)) {
5899
5956
  const b = base[key];
5900
5957
  const w = work[key];
5901
- if (!hasOwn(base, key)) changes.push({
5958
+ if (!hasOwn$1(base, key)) changes.push({
5902
5959
  path: [...path, key],
5903
5960
  deleted: false,
5904
5961
  value: w
@@ -5911,7 +5968,7 @@ function diff(base, work, path, changes, ancestors) {
5911
5968
  value: w
5912
5969
  });
5913
5970
  }
5914
- for (const key of Object.keys(base)) if (!hasOwn(work, key)) changes.push({
5971
+ for (const key of Object.keys(base)) if (!hasOwn$1(work, key)) changes.push({
5915
5972
  path: [...path, key],
5916
5973
  deleted: true
5917
5974
  });
@@ -5922,7 +5979,7 @@ function isApplied(ns, change) {
5922
5979
  const parent = getByPath(ns, change.path.slice(0, -1));
5923
5980
  if (parent === null || typeof parent !== "object") return change.deleted;
5924
5981
  const key = change.path[change.path.length - 1];
5925
- if (!hasOwn(parent, key)) return change.deleted;
5982
+ if (!hasOwn$1(parent, key)) return change.deleted;
5926
5983
  return !change.deleted && deepEqual(parent[key], change.value);
5927
5984
  }
5928
5985
  function applyChange(ns, change) {
@@ -5947,7 +6004,7 @@ function mirror(draft, ns, change, scopes = activeScopes) {
5947
6004
  });
5948
6005
  } catch {
5949
6006
  const stored = draft[ns][root];
5950
- if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
6007
+ if (hasOwn$1(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
5951
6008
  else delete copy[root];
5952
6009
  }
5953
6010
  }
@@ -5971,7 +6028,7 @@ function changedPath(ns, segments) {
5971
6028
  if (!isMergeable(node)) break;
5972
6029
  const key = String(segment);
5973
6030
  path.push(key);
5974
- node = hasOwn(node, key) ? node[key] : void 0;
6031
+ node = hasOwn$1(node, key) ? node[key] : void 0;
5975
6032
  }
5976
6033
  return path;
5977
6034
  }
@@ -5979,7 +6036,7 @@ function changedPath(ns, segments) {
5979
6036
  function changeTo(ns, path) {
5980
6037
  const parent = getByPath(ns, path.slice(0, -1));
5981
6038
  const key = path[path.length - 1];
5982
- return isMergeable(parent) && hasOwn(parent, key) ? {
6039
+ return isMergeable(parent) && hasOwn$1(parent, key) ? {
5983
6040
  path,
5984
6041
  deleted: false,
5985
6042
  value: parent[key]
@@ -5995,7 +6052,7 @@ function changeTo(ns, path) {
5995
6052
  * replaces exactly what it replaced in program order.
5996
6053
  */
5997
6054
  function writtenPaths(before, after, patches) {
5998
- 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]));
6055
+ 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]));
5999
6056
  const seen = /* @__PURE__ */ new Set();
6000
6057
  const changes = [];
6001
6058
  for (const patch of patches) {
@@ -6067,7 +6124,7 @@ function routeStoreUpdate(recipe) {
6067
6124
  try {
6068
6125
  applyChange(namespaces[ns], owned(change));
6069
6126
  } catch {
6070
- if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
6127
+ if (hasOwn$1(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
6071
6128
  else delete namespaces[ns][root];
6072
6129
  }
6073
6130
  mirror(namespaces, ns, change);
@@ -6153,8 +6210,8 @@ function resync(scope) {
6153
6210
  for (const ns of NAMESPACES) {
6154
6211
  const work = scope.work[ns];
6155
6212
  const stored = state[ns];
6156
- for (const key of Object.keys(work)) if (!hasOwn(stored, key)) delete work[key];
6157
- for (const key of Object.keys(stored)) if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
6213
+ for (const key of Object.keys(work)) if (!hasOwn$1(stored, key)) delete work[key];
6214
+ for (const key of Object.keys(stored)) if (!hasOwn$1(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
6158
6215
  }
6159
6216
  scope.base = cloneNamespaces(state);
6160
6217
  }
@@ -6194,11 +6251,10 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
6194
6251
  keepRunning: false
6195
6252
  }]);
6196
6253
  for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
6197
- for (const key of Object.keys(mergedLocals)) if (!hasOwn(localsClone, key)) scopeUpdate(key, void 0);
6254
+ for (const key of Object.keys(mergedLocals)) if (!hasOwn$1(localsClone, key)) scopeUpdate(key, void 0);
6198
6255
  }
6199
6256
  //#endregion
6200
6257
  //#region src/triggers.ts
6201
- var nextId = 0;
6202
6258
  var triggers = [];
6203
6259
  var checking = false;
6204
6260
  var dialogQueue = [];
@@ -6214,12 +6270,10 @@ function evalCondition(condition) {
6214
6270
  }
6215
6271
  }
6216
6272
  function registerTrigger(condition, callbackOrOptions, macroKey) {
6217
- const id = nextId++;
6218
6273
  const isCallback = typeof callbackOrOptions === "function";
6219
6274
  const options = isCallback ? void 0 : callbackOrOptions;
6220
6275
  const callback = isCallback ? callbackOrOptions : void 0;
6221
6276
  const trigger = {
6222
- id,
6223
6277
  name: options?.name,
6224
6278
  condition,
6225
6279
  callback,
@@ -6231,7 +6285,7 @@ function registerTrigger(condition, callbackOrOptions, macroKey) {
6231
6285
  triggers.push(trigger);
6232
6286
  triggers.sort((a, b) => b.priority - a.priority);
6233
6287
  return () => {
6234
- triggers = triggers.filter((t) => t.id !== id);
6288
+ triggers = triggers.filter((t) => t !== trigger);
6235
6289
  };
6236
6290
  }
6237
6291
  function addTrigger(condition, callbackOrOptions) {
@@ -6283,7 +6337,7 @@ function runCheckLoop() {
6283
6337
  trigger.lastResult = result;
6284
6338
  if (result && wasFalse) {
6285
6339
  anyFired = true;
6286
- if (trigger.options?.once) triggers = triggers.filter((t) => t.id !== trigger.id);
6340
+ if (trigger.options?.once) triggers = triggers.filter((t) => t !== trigger);
6287
6341
  fireTrigger(trigger);
6288
6342
  }
6289
6343
  }
@@ -6349,7 +6403,6 @@ function reinitTriggerState() {
6349
6403
  function resetTriggers() {
6350
6404
  triggers = [];
6351
6405
  dialogQueue = [];
6352
- nextId = 0;
6353
6406
  dialogHostCallbacks?.closeAll();
6354
6407
  }
6355
6408
  function subscribeTriggerDialogs(cb) {
@@ -6716,20 +6769,65 @@ function loadedEntryMoment(payload) {
6716
6769
  return moment;
6717
6770
  }
6718
6771
  /**
6719
- * Copy the variables that changed between `before` and `after` (the live
6720
- * variables around the `beforesave` hooks) into the payload's snapshot of the
6721
- * saved moment. A load restores that snapshot, the state on entering the
6722
- * passage, and runs the passage again, so data a hook adds to a save would
6723
- * otherwise be lost on load (#227). Only the payload's copy changes: the live
6724
- * history keeps the recorded snapshot (#159). Store updates are immutable, so
6725
- * a value a hook did not touch keeps its identity.
6772
+ * Write the changes between `before` and `after` (the live variables around
6773
+ * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
6774
+ * load restores that snapshot, the state on entering the passage, and runs
6775
+ * the passage again, so data a hook adds to a save would otherwise be lost
6776
+ * on load (#227). Only the property paths the hooks changed are written: a
6777
+ * whole variable would bring along what the passage did to the rest of it,
6778
+ * which the passage then does again on load (#232). Only the payload's copy
6779
+ * changes: the live history keeps the recorded snapshot (#159).
6726
6780
  */
6727
6781
  function keepHookWrites(payload, before, after) {
6728
6782
  const moment = payload.history[payload.historyIndex];
6729
6783
  if (!moment) return;
6730
- const has = (vars, key) => Object.prototype.hasOwnProperty.call(vars, key);
6731
- for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) if (!has(after, key)) delete moment.variables[key];
6732
- else if (!has(before, key) || after[key] !== before[key]) moment.variables[key] = deepClone(after[key]);
6784
+ mergeHookWrites(moment.variables, before, after, /* @__PURE__ */ new Set());
6785
+ }
6786
+ var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
6787
+ /**
6788
+ * Whether changes between `a` and `b` merge key by key: both are arrays,
6789
+ * or both are objects of one class (see isMergeable).
6790
+ */
6791
+ function mergeable(a, b) {
6792
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b);
6793
+ return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
6794
+ }
6795
+ /**
6796
+ * Apply the hooks' change of a value, from `before` to `after`, to the
6797
+ * snapshot's copy of it, `target[key]`. Store updates are immutable, so a
6798
+ * value no hook touched keeps its identity; one rebuilt with equal content
6799
+ * (mutation code commits whole values) is no change either. Where `target`
6800
+ * lacks the key or holds another kind of value (the passage created or
6801
+ * replaced it), the hooks' whole value is written.
6802
+ */
6803
+ function mergeHookWrite(target, key, before, after, ancestors) {
6804
+ if (Object.is(before, after)) return;
6805
+ if (mergeable(before, after) && hasOwn(target, key) && mergeable(after, target[key]) && !ancestors.has(after)) mergeHookWrites(target[key], before, after, ancestors);
6806
+ else if (!deepEqual(before, after)) target[key] = deepClone(after);
6807
+ }
6808
+ /**
6809
+ * Apply the changes between `before` and `after`, two objects or two arrays
6810
+ * (see mergeable()), to the snapshot's `target`. Array elements merge by
6811
+ * index. Elements the hooks removed from an array's end are removed from
6812
+ * the snapshot's array at the same indices, and elements they added are
6813
+ * appended to it: where the passage resized the array, its indices do not
6814
+ * line up with the snapshot's, and the passage resizes it again on load.
6815
+ */
6816
+ function mergeHookWrites(target, before, after, ancestors) {
6817
+ ancestors.add(after);
6818
+ if (Array.isArray(target)) {
6819
+ const b = before;
6820
+ const a = after;
6821
+ const shared = Math.min(b.length, a.length, target.length);
6822
+ for (let i = 0; i < shared; i++) mergeHookWrite(target, String(i), b[i], a[i], ancestors);
6823
+ if (a.length < b.length) target.length = Math.min(target.length, a.length);
6824
+ for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
6825
+ } else {
6826
+ for (const key of Object.keys(before)) if (!hasOwn(after, key)) delete target[key];
6827
+ for (const key of Object.keys(after)) if (hasOwn(before, key)) mergeHookWrite(target, key, before[key], after[key], ancestors);
6828
+ else target[key] = deepClone(after[key]);
6829
+ }
6830
+ ancestors.delete(after);
6733
6831
  }
6734
6832
  /** Restore or reset PRNG from a history moment's snapshot. */
6735
6833
  function restorePRNGFromMoment(moment) {
@@ -6828,8 +6926,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
6828
6926
  temporary: createNamespace(),
6829
6927
  history: [],
6830
6928
  historyIndex: -1,
6831
- visitCounts: {},
6832
- renderCounts: {},
6929
+ visitCounts: createCounts(),
6930
+ renderCounts: createCounts(),
6833
6931
  knownSaves: {},
6834
6932
  playthroughId: "",
6835
6933
  maxHistory: 40,
@@ -6879,8 +6977,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
6879
6977
  timestamp: Date.now()
6880
6978
  }];
6881
6979
  state.historyIndex = 0;
6882
- state.visitCounts = { [startPassage.name]: 1 };
6883
- state.renderCounts = { [startPassage.name]: 1 };
6980
+ state.visitCounts = createCounts(null, startPassage.name);
6981
+ state.renderCounts = createCounts(null, startPassage.name);
6884
6982
  });
6885
6983
  lastNavigationVars = get().variables;
6886
6984
  replaceStateNow();
@@ -6927,8 +7025,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
6927
7025
  });
6928
7026
  state.historyIndex = state.history.length - 1;
6929
7027
  trimHistory(state);
6930
- state.visitCounts[passageName] = (state.visitCounts[passageName] ?? 0) + 1;
6931
- state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
7028
+ state.visitCounts = createCounts(state.visitCounts, passageName);
7029
+ state.renderCounts = createCounts(state.renderCounts, passageName);
6932
7030
  });
6933
7031
  enteredMoment = {
6934
7032
  navigationId: get().navigationId,
@@ -7032,7 +7130,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7032
7130
  },
7033
7131
  trackRender: (passageName) => {
7034
7132
  set((state) => {
7035
- state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
7133
+ state.renderCounts = createCounts(state.renderCounts, passageName);
7036
7134
  });
7037
7135
  },
7038
7136
  restart: () => {
@@ -7063,8 +7161,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
7063
7161
  timestamp: Date.now()
7064
7162
  }];
7065
7163
  state.historyIndex = 0;
7066
- state.visitCounts = { [startPassage.name]: 1 };
7067
- state.renderCounts = { [startPassage.name]: 1 };
7164
+ state.visitCounts = createCounts(null, startPassage.name);
7165
+ state.renderCounts = createCounts(null, startPassage.name);
7068
7166
  if (!keepDeferred) state.renderDeferred = false;
7069
7167
  });
7070
7168
  lastNavigationVars = get().variables;
@@ -7287,8 +7385,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
7287
7385
  }));
7288
7386
  state.historyIndex = Math.max(0, Math.min(payload.historyIndex, state.history.length - 1));
7289
7387
  trimHistory(state);
7290
- state.visitCounts = payload.visitCounts ?? {};
7291
- state.renderCounts = payload.renderCounts ?? {};
7388
+ state.visitCounts = createCounts(payload.visitCounts);
7389
+ state.renderCounts = createCounts(payload.renderCounts);
7292
7390
  state.temporary = createNamespace();
7293
7391
  state.transient = createNamespace(deepClone(get().transientDefaults));
7294
7392
  });
@@ -7298,7 +7396,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7298
7396
  if (prng) restorePRNG(prng.seed, prng.pull);
7299
7397
  else resetPRNG();
7300
7398
  persistSession(get);
7301
- emit("afterload", slot);
7399
+ withoutDraws(() => emit("afterload", slot));
7302
7400
  },
7303
7401
  getHistoryVariables: (index) => {
7304
7402
  return deepClone(reconstructVarsAt(index));
@@ -7632,7 +7730,7 @@ function parseStoryData() {
7632
7730
  //#endregion
7633
7731
  //#region src/settings.ts
7634
7732
  var definitions = /* @__PURE__ */ new Map();
7635
- var values = {};
7733
+ var values = createNamespace();
7636
7734
  var storageLoaded = false;
7637
7735
  var unsubscribeStoryData = null;
7638
7736
  function storageKey() {
@@ -7657,10 +7755,7 @@ function loadFromStorage() {
7657
7755
  const raw = localStorage.getItem(storageKey());
7658
7756
  if (raw) {
7659
7757
  const parsed = JSON.parse(raw);
7660
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) values = {
7661
- ...values,
7662
- ...parsed
7663
- };
7758
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) values = createNamespace(values, parsed);
7664
7759
  }
7665
7760
  } catch {}
7666
7761
  }
@@ -7670,7 +7765,7 @@ var settings = {
7670
7765
  type: "toggle",
7671
7766
  config
7672
7767
  });
7673
- if (!(name in values)) values[name] = config.default;
7768
+ if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7674
7769
  loadFromStorage();
7675
7770
  },
7676
7771
  addList(name, config) {
@@ -7678,7 +7773,7 @@ var settings = {
7678
7773
  type: "list",
7679
7774
  config
7680
7775
  });
7681
- if (!(name in values)) values[name] = config.default;
7776
+ if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7682
7777
  loadFromStorage();
7683
7778
  },
7684
7779
  addRange(name, config) {
@@ -7686,22 +7781,22 @@ var settings = {
7686
7781
  type: "range",
7687
7782
  config
7688
7783
  });
7689
- if (!(name in values)) values[name] = config.default;
7784
+ if (!Object.prototype.hasOwnProperty.call(values, name)) values[name] = config.default;
7690
7785
  loadFromStorage();
7691
7786
  },
7692
7787
  get(name) {
7693
- return values[name];
7788
+ return ownValue(values, name);
7694
7789
  },
7695
7790
  getToggle(name) {
7696
- const v = values[name];
7791
+ const v = ownValue(values, name);
7697
7792
  return typeof v === "boolean" ? v : false;
7698
7793
  },
7699
7794
  getList(name) {
7700
- const v = values[name];
7795
+ const v = ownValue(values, name);
7701
7796
  return typeof v === "string" ? v : "";
7702
7797
  },
7703
7798
  getRange(name) {
7704
- const v = values[name];
7799
+ const v = ownValue(values, name);
7705
7800
  return typeof v === "number" ? v : 0;
7706
7801
  },
7707
7802
  set(name, value) {
@@ -7720,36 +7815,66 @@ var settings = {
7720
7815
  };
7721
7816
  //#endregion
7722
7817
  //#region src/action-registry.ts
7818
+ /**
7819
+ * The registrations holding each ID, oldest first; the newest is the one
7820
+ * listed. Generated IDs don't collide, but author IDs can, and one control's
7821
+ * cleanup must not remove another control holding the same ID (#233).
7822
+ */
7723
7823
  var actions = /* @__PURE__ */ new Map();
7724
7824
  var idCounters = /* @__PURE__ */ new Map();
7825
+ /**
7826
+ * The counters reset on every navigation, so a passage's controls get the
7827
+ * same IDs each time it is shown. Controls that stay mounted across
7828
+ * navigations (e.g. in StoryInterface) keep theirs, so allocation skips every
7829
+ * ID still registered (#233).
7830
+ */
7725
7831
  function generateActionId(type, key, authorId) {
7726
7832
  if (authorId) return authorId;
7727
7833
  const base = `${type}:${key}`;
7728
- const count = (idCounters.get(base) ?? 0) + 1;
7834
+ let count = idCounters.get(base) ?? 0;
7835
+ let id;
7836
+ do {
7837
+ count++;
7838
+ id = count === 1 ? base : `${base}:${count}`;
7839
+ } while (actions.has(id));
7729
7840
  idCounters.set(base, count);
7730
- return count === 1 ? base : `${base}:${count}`;
7841
+ return id;
7731
7842
  }
7732
7843
  function registerAction(action) {
7733
- actions.set(action.id, action);
7844
+ const id = action.id;
7845
+ const entry = { action };
7846
+ const entries = actions.get(id);
7847
+ if (entries) {
7848
+ console.warn(`spindle: two mounted controls share the action ID "${id}"; Story.getActions() lists only the newer one.`);
7849
+ entries.push(entry);
7850
+ } else actions.set(id, [entry]);
7734
7851
  notify();
7735
- return () => {
7736
- actions.delete(action.id);
7737
- notify();
7738
- };
7739
- }
7740
- function updateAction(action) {
7741
- actions.set(action.id, action);
7742
- notify();
7743
- return () => {
7744
- actions.delete(action.id);
7745
- notify();
7852
+ return {
7853
+ update(next) {
7854
+ const current = actions.get(id);
7855
+ if (!current?.includes(entry)) return;
7856
+ entry.action = next;
7857
+ if (current[current.length - 1] === entry) notify();
7858
+ },
7859
+ unregister() {
7860
+ const current = actions.get(id);
7861
+ const index = current ? current.indexOf(entry) : -1;
7862
+ if (!current || index === -1) return;
7863
+ current.splice(index, 1);
7864
+ if (current.length === 0) actions.delete(id);
7865
+ if (index === current.length) notify();
7866
+ }
7746
7867
  };
7747
7868
  }
7748
7869
  function getActions() {
7749
- return Array.from(actions.values());
7870
+ return Array.from(actions.values(), listed);
7750
7871
  }
7751
7872
  function getAction(id) {
7752
- return actions.get(id);
7873
+ const entries = actions.get(id);
7874
+ return entries && listed(entries);
7875
+ }
7876
+ function listed(entries) {
7877
+ return entries[entries.length - 1].action;
7753
7878
  }
7754
7879
  function resetIdCounters() {
7755
7880
  idCounters.clear();
@@ -7779,14 +7904,19 @@ function useAction(opts) {
7779
7904
  if (opts.disabled !== void 0) action.disabled = opts.disabled;
7780
7905
  return action;
7781
7906
  };
7782
- useLayoutEffect(() => registerAction(buildAction()), [id]);
7907
+ const registration = useRef(null);
7908
+ useLayoutEffect(() => {
7909
+ const own = registerAction(buildAction());
7910
+ registration.current = own;
7911
+ return () => own.unregister();
7912
+ }, [id]);
7783
7913
  const registered = useRef(false);
7784
7914
  useLayoutEffect(() => {
7785
7915
  if (!registered.current) {
7786
7916
  registered.current = true;
7787
7917
  return;
7788
7918
  }
7789
- updateAction(buildAction());
7919
+ registration.current?.update(buildAction());
7790
7920
  }, [
7791
7921
  opts.type,
7792
7922
  opts.label,
@@ -8087,33 +8217,33 @@ function createStoryAPI() {
8087
8217
  },
8088
8218
  visited(name) {
8089
8219
  const state = useStoryStore.getState();
8090
- return state.visitCounts[name ?? state.currentPassage] ?? 0;
8220
+ return countOf(state.visitCounts, name ?? state.currentPassage);
8091
8221
  },
8092
8222
  hasVisited(name) {
8093
8223
  return this.visited(name) > 0;
8094
8224
  },
8095
8225
  hasVisitedAny(...names) {
8096
8226
  const { visitCounts } = useStoryStore.getState();
8097
- return names.some((n) => (visitCounts[n] ?? 0) > 0);
8227
+ return names.some((n) => countOf(visitCounts, n) > 0);
8098
8228
  },
8099
8229
  hasVisitedAll(...names) {
8100
8230
  const { visitCounts } = useStoryStore.getState();
8101
- return names.every((n) => (visitCounts[n] ?? 0) > 0);
8231
+ return names.every((n) => countOf(visitCounts, n) > 0);
8102
8232
  },
8103
8233
  rendered(name) {
8104
8234
  const state = useStoryStore.getState();
8105
- return state.renderCounts[name ?? state.currentPassage] ?? 0;
8235
+ return countOf(state.renderCounts, name ?? state.currentPassage);
8106
8236
  },
8107
8237
  hasRendered(name) {
8108
8238
  return this.rendered(name) > 0;
8109
8239
  },
8110
8240
  hasRenderedAny(...names) {
8111
8241
  const { renderCounts } = useStoryStore.getState();
8112
- return names.some((n) => (renderCounts[n] ?? 0) > 0);
8242
+ return names.some((n) => countOf(renderCounts, n) > 0);
8113
8243
  },
8114
8244
  hasRenderedAll(...names) {
8115
8245
  const { renderCounts } = useStoryStore.getState();
8116
- return names.every((n) => (renderCounts[n] ?? 0) > 0);
8246
+ return names.every((n) => countOf(renderCounts, n) > 0);
8117
8247
  },
8118
8248
  currentPassage() {
8119
8249
  const state = useStoryStore.getState();
@@ -8588,19 +8718,19 @@ defineMacro({
8588
8718
  name: "set",
8589
8719
  render({ rawArgs }, ctx) {
8590
8720
  const ran = ctx.hooks.useRef(false);
8591
- const error = ctx.hooks.useRef(null);
8721
+ const failure = ctx.hooks.useRef(null);
8592
8722
  if (!ran.current) {
8593
8723
  ran.current = true;
8594
8724
  try {
8595
8725
  ctx.mutate(rawArgs);
8596
8726
  } catch (err) {
8597
- error.current = err;
8727
+ failure.current = { error: err };
8598
8728
  console.error(`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`, err);
8599
8729
  }
8600
8730
  }
8601
- if (error.current) return /* @__PURE__ */ jsx(MacroError, {
8731
+ if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
8602
8732
  macro: "set",
8603
- error: error.current
8733
+ error: failure.current.error
8604
8734
  });
8605
8735
  return null;
8606
8736
  }
@@ -8630,6 +8760,12 @@ defineMacro({
8630
8760
  });
8631
8761
  //#endregion
8632
8762
  //#region src/components/macros/Computed.tsx
8763
+ /**
8764
+ * The previous output before the first successful evaluation. Distinct from
8765
+ * every value an expression can produce, so the first result is always
8766
+ * applied, even `undefined`; a first evaluation that throws leaves it unset.
8767
+ */
8768
+ var UNSET = Symbol("unset");
8633
8769
  function parseComputedArgs(rawArgs) {
8634
8770
  const trimmed = rawArgs.trim();
8635
8771
  let depth = 0;
@@ -8658,10 +8794,11 @@ function parseComputedArgs(rawArgs) {
8658
8794
  /**
8659
8795
  * Evaluate `expr` against the current values — the store's state and the
8660
8796
  * enclosing locals scope's live values (`getLocals`) — and write the result
8661
- * to the target if it changed. Reading live values rather than the render's
8662
- * snapshot means the first computation sees a local a preceding {set} just
8663
- * assigned (the scope's context value only catches up on its re-render), and
8664
- * the first computation and later recomputations read the same source.
8797
+ * to the target if it differs from the previous output (always, the first
8798
+ * time). Reading live values rather than the render's snapshot means the
8799
+ * first computation sees a local a preceding {set} just assigned (the scope's
8800
+ * context value only catches up on its re-render), and the first computation
8801
+ * and later recomputations read the same source.
8665
8802
  */
8666
8803
  function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, localsUpdate) {
8667
8804
  let newValue;
@@ -8705,7 +8842,7 @@ defineMacro({
8705
8842
  const isTemp = target.startsWith("_");
8706
8843
  const name = target.slice(1);
8707
8844
  const localsUpdate = isLocal ? ctx.update : null;
8708
- const prevOutput = ctx.hooks.useRef(void 0);
8845
+ const prevOutput = ctx.hooks.useRef(UNSET);
8709
8846
  const ran = ctx.hooks.useRef(false);
8710
8847
  if (!ran.current) {
8711
8848
  ran.current = true;
@@ -10066,7 +10203,7 @@ defineMacro({
10066
10203
  name: "unset",
10067
10204
  render({ rawArgs }, ctx) {
10068
10205
  const ran = ctx.hooks.useRef(false);
10069
- const error = ctx.hooks.useRef(null);
10206
+ const failure = ctx.hooks.useRef(null);
10070
10207
  if (!ran.current) {
10071
10208
  ran.current = true;
10072
10209
  const state = useStoryStore.getState();
@@ -10078,13 +10215,13 @@ defineMacro({
10078
10215
  else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
10079
10216
  else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
10080
10217
  } catch (err) {
10081
- error.current = err;
10218
+ failure.current = { error: err };
10082
10219
  console.error(`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`, err);
10083
10220
  }
10084
10221
  }
10085
- if (error.current) return /* @__PURE__ */ jsx(MacroError, {
10222
+ if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
10086
10223
  macro: "unset",
10087
- error: error.current
10224
+ error: failure.current.error
10088
10225
  });
10089
10226
  return null;
10090
10227
  }