@rohal12/spindle 0.50.0 → 0.51.0

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.
@@ -43,13 +43,25 @@ function isSavePayload(value) {
43
43
  //#region src/markup/tokenizer.ts
44
44
  /** Tag name must start with a letter (covers standard and custom elements). */
45
45
  var VALID_TAG_START = /[a-zA-Z]/;
46
+ /** HTML void elements: never have children or a closing tag. */
46
47
  var HTML_VOID_TAGS = new Set([
48
+ "area",
49
+ "base",
47
50
  "br",
48
51
  "col",
52
+ "embed",
49
53
  "hr",
50
54
  "img",
55
+ "input",
56
+ "link",
57
+ "meta",
58
+ "param",
59
+ "source",
60
+ "track",
51
61
  "wbr"
52
62
  ]);
63
+ /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
64
+ var RAW_BODY_MACROS = new Set(["do"]);
53
65
  /**
54
66
  * Parse a Twine link interior into display and target.
55
67
  * Supports: display|target, display->target, target<-display, plain
@@ -154,11 +166,14 @@ function parseHtmlAttributes(input, j) {
154
166
  const quote = input[j];
155
167
  j++;
156
168
  const valStart = j;
157
- let braceDepth = 0;
158
169
  while (j < input.length) {
159
- if (input[j] === "{") braceDepth++;
160
- else if (input[j] === "}") braceDepth--;
161
- else if (input[j] === quote && braceDepth <= 0) break;
170
+ if (input[j] === "{") {
171
+ const closeIdx = scanBalancedBrace(input, j + 1);
172
+ if (closeIdx !== -1) {
173
+ j = closeIdx + 1;
174
+ continue;
175
+ }
176
+ } else if (input[j] === quote) break;
162
177
  j++;
163
178
  }
164
179
  attributes[attrName] = input.slice(valStart, j);
@@ -176,17 +191,74 @@ function parseHtmlAttributes(input, j) {
176
191
  };
177
192
  }
178
193
  /**
194
+ * Skip a '…' or "…" string literal opening at i.
195
+ * Returns the index just past the closing quote, or -1 if the string is
196
+ * not closed on the same line (JS strings can't span lines unescaped).
197
+ */
198
+ function skipQuoted(input, i) {
199
+ const quote = input[i];
200
+ let j = i + 1;
201
+ while (j < input.length) {
202
+ const c = input[j];
203
+ if (c === "\\") j += 2;
204
+ else if (c === quote) return j + 1;
205
+ else if (c === "\n") return -1;
206
+ else j++;
207
+ }
208
+ return -1;
209
+ }
210
+ /**
211
+ * Skip a `…` template literal opening at i, including ${…} parts.
212
+ * Returns the index just past the closing backtick, or -1 if unclosed.
213
+ */
214
+ function skipTemplate(input, i) {
215
+ let j = i + 1;
216
+ while (j < input.length) {
217
+ const c = input[j];
218
+ if (c === "\\") j += 2;
219
+ else if (c === "`") return j + 1;
220
+ else if (c === "$" && input[j + 1] === "{") {
221
+ const closeIdx = scanBalancedBrace(input, j + 2);
222
+ if (closeIdx === -1) return -1;
223
+ j = closeIdx + 1;
224
+ } else j++;
225
+ }
226
+ return -1;
227
+ }
228
+ /**
229
+ * A quote directly after a letter/digit is an apostrophe (don't), not a
230
+ * string; after a backslash it is an escaped attribute delimiter (\").
231
+ */
232
+ var NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
233
+ /**
179
234
  * Scan for the balanced closing } starting at position i (just past the {).
235
+ * Braces inside string and template literals are ignored. A quote that
236
+ * can't start a string (apostrophe, escaped, unterminated) counts as text.
180
237
  * Returns the index of the closing } or -1 if unbalanced.
181
238
  */
182
239
  function scanBalancedBrace(input, i) {
183
240
  let depth = 1;
184
- while (i < input.length && depth > 0) {
185
- if (input[i] === "{") depth++;
186
- else if (input[i] === "}") depth--;
187
- if (depth > 0) i++;
241
+ while (i < input.length) {
242
+ const c = input[i];
243
+ if (c === "{") depth++;
244
+ else if (c === "}") {
245
+ if (--depth === 0) return i;
246
+ } else if ((c === "\"" || c === "'") && !(i > 0 && NON_STRING_QUOTE_PREFIX.test(input[i - 1]))) {
247
+ const end = skipQuoted(input, i);
248
+ if (end !== -1) {
249
+ i = end;
250
+ continue;
251
+ }
252
+ } else if (c === "`") {
253
+ const end = skipTemplate(input, i);
254
+ if (end !== -1) {
255
+ i = end;
256
+ continue;
257
+ }
258
+ }
259
+ i++;
188
260
  }
189
- return depth === 0 ? i : -1;
261
+ return -1;
190
262
  }
191
263
  /**
192
264
  * Single-pass tokenizer for Twine passage content.
@@ -204,6 +276,31 @@ function tokenize(input) {
204
276
  end
205
277
  });
206
278
  }
279
+ /**
280
+ * After an opening raw-body macro ({do}), emit everything up to the
281
+ * first {/name} as a single text token so JavaScript source is not
282
+ * parsed as markup. A literal "{/do}" inside the code would end it early.
283
+ * Without a closer, nothing is consumed and the AST builder reports it.
284
+ */
285
+ function consumeRawBody(name, isClose) {
286
+ const lower = name.toLowerCase();
287
+ if (isClose || !RAW_BODY_MACROS.has(lower)) return;
288
+ const closeRe = new RegExp(`\\{/${lower}\\s*\\}`, "gi");
289
+ closeRe.lastIndex = i;
290
+ const m = closeRe.exec(input);
291
+ if (!m) return;
292
+ const closeStart = m.index;
293
+ const closeEnd = closeStart + m[0].length;
294
+ flushText(closeStart);
295
+ tokens.push({
296
+ type: "macro",
297
+ ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
298
+ start: closeStart,
299
+ end: closeEnd
300
+ });
301
+ i = closeEnd;
302
+ textStart = closeEnd;
303
+ }
207
304
  while (i < input.length) {
208
305
  if (input[i] === "\\" && (input[i + 1] === "{" || input[i + 1] === "}")) {
209
306
  flushText(i);
@@ -435,21 +532,15 @@ function tokenize(input) {
435
532
  continue;
436
533
  }
437
534
  if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
438
- i = afterSelectors;
439
- let depth = 1;
440
- const contentStart = i;
441
- while (i < input.length && depth > 0) {
442
- if (input[i] === "{") depth++;
443
- else if (input[i] === "}") depth--;
444
- if (depth > 0) i++;
445
- }
446
- if (depth !== 0) {
535
+ const contentStart = afterSelectors;
536
+ const closeIdx = scanBalancedBrace(input, contentStart);
537
+ if (closeIdx === -1) {
447
538
  i = start + 1;
448
539
  textStart = start;
449
540
  continue;
450
541
  }
451
- const content = input.slice(contentStart, i);
452
- i++;
542
+ const content = input.slice(contentStart, closeIdx);
543
+ i = closeIdx + 1;
453
544
  const { name, rawArgs, isClose } = parseMacroContent(content);
454
545
  const token = {
455
546
  type: "macro",
@@ -463,6 +554,7 @@ function tokenize(input) {
463
554
  if (id) token.id = id;
464
555
  tokens.push(token);
465
556
  textStart = i;
557
+ consumeRawBody(name, isClose);
466
558
  continue;
467
559
  }
468
560
  i = start + 1;
@@ -611,21 +703,15 @@ function tokenize(input) {
611
703
  }
612
704
  if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
613
705
  flushText(i);
614
- i++;
615
- let depth = 1;
616
- const contentStart = i;
617
- while (i < input.length && depth > 0) {
618
- if (input[i] === "{") depth++;
619
- else if (input[i] === "}") depth--;
620
- if (depth > 0) i++;
621
- }
622
- if (depth !== 0) {
706
+ const contentStart = i + 1;
707
+ const closeIdx = scanBalancedBrace(input, contentStart);
708
+ if (closeIdx === -1) {
623
709
  i = start + 1;
624
710
  textStart = start;
625
711
  continue;
626
712
  }
627
- const content = input.slice(contentStart, i);
628
- i++;
713
+ const content = input.slice(contentStart, closeIdx);
714
+ i = closeIdx + 1;
629
715
  const { name, rawArgs, isClose } = parseMacroContent(content);
630
716
  tokens.push({
631
717
  type: "macro",
@@ -636,6 +722,7 @@ function tokenize(input) {
636
722
  end: i
637
723
  });
638
724
  textStart = i;
725
+ consumeRawBody(name, isClose);
639
726
  continue;
640
727
  }
641
728
  i++;
@@ -655,6 +742,11 @@ function tokenize(input) {
655
742
  if (input[j] === ">") {
656
743
  j++;
657
744
  flushText(start);
745
+ if (HTML_VOID_TAGS.has(tagLower)) {
746
+ textStart = j;
747
+ i = j;
748
+ continue;
749
+ }
658
750
  tokens.push({
659
751
  type: "html",
660
752
  tag,
@@ -1302,14 +1394,8 @@ function interpolate(template, variables, temporary, locals, transient = {}) {
1302
1394
  result += "{";
1303
1395
  continue;
1304
1396
  }
1305
- let depth = 1;
1306
- let j = i;
1307
- while (j < template.length && depth > 0) {
1308
- j++;
1309
- if (template[j] === "{") depth++;
1310
- else if (template[j] === "}") depth--;
1311
- }
1312
- if (depth !== 0) {
1397
+ const j = scanBalancedBrace(template, i);
1398
+ if (j === -1) {
1313
1399
  result += "{";
1314
1400
  continue;
1315
1401
  }
@@ -1515,6 +1601,10 @@ function WidgetBody({ body, parentValues, ownKeys }) {
1515
1601
  valuesRef.current = localState;
1516
1602
  const getValues = useCallback(() => valuesRef.current, []);
1517
1603
  const update = useCallback((key, value) => {
1604
+ valuesRef.current = {
1605
+ ...valuesRef.current,
1606
+ [key]: value
1607
+ };
1518
1608
  setLocalMutations((prev) => ({
1519
1609
  ...prev,
1520
1610
  [key]: value
@@ -1705,13 +1795,48 @@ var INLINE_ELEMENTS = new Set([
1705
1795
  "var",
1706
1796
  "wbr"
1707
1797
  ]);
1798
+ /**
1799
+ * HTML boolean attributes. Their presence means "on", but a parsed bare or
1800
+ * `=""` attribute has the value '', which Preact would assign to the DOM
1801
+ * property as a falsy value — so present ones are passed as `true` (#177).
1802
+ */
1803
+ var BOOLEAN_ATTRIBUTES = new Set([
1804
+ "allowfullscreen",
1805
+ "async",
1806
+ "autofocus",
1807
+ "autoplay",
1808
+ "checked",
1809
+ "controls",
1810
+ "default",
1811
+ "defer",
1812
+ "disabled",
1813
+ "formnovalidate",
1814
+ "hidden",
1815
+ "inert",
1816
+ "ismap",
1817
+ "itemscope",
1818
+ "loop",
1819
+ "multiple",
1820
+ "muted",
1821
+ "nomodule",
1822
+ "novalidate",
1823
+ "open",
1824
+ "playsinline",
1825
+ "readonly",
1826
+ "required",
1827
+ "reversed",
1828
+ "selected"
1829
+ ]);
1830
+ function isPresentBooleanAttribute(name, value) {
1831
+ return value === "" && BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
1832
+ }
1708
1833
  function HtmlNodeRenderer({ node }) {
1709
1834
  const resolve = useInterpolate();
1710
1835
  const nobr = useContext(NobrContext);
1711
1836
  const locals = useContext(LocalsValuesContext);
1712
1837
  const inSvg = useContext(SvgContext);
1713
1838
  const attrs = {};
1714
- for (const [k, v] of Object.entries(node.attributes)) attrs[k] = resolve(v) ?? v;
1839
+ for (const [k, v] of Object.entries(node.attributes)) attrs[k] = isPresentBooleanAttribute(k, v) ? true : resolve(v) ?? v;
1715
1840
  const isSvgRoot = node.tag.toLowerCase() === "svg";
1716
1841
  const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
1717
1842
  const children = node.children.length > 0 ? inSvg || isSvgRoot ? renderInlineNodes(node.children) : renderNodes(node.children, {
@@ -1735,6 +1860,23 @@ function ChildrenSlot() {
1735
1860
  locals
1736
1861
  }) });
1737
1862
  }
1863
+ /**
1864
+ * Stable per-node keys. Keying rendered children by AST node identity (not
1865
+ * by position) makes Preact remount components when different content
1866
+ * occupies the same slot — e.g. when an {if}/{switch} branch or an included
1867
+ * passage changes — so mount-only macros like {set} and {do} run for the new
1868
+ * content, while re-rendering the same AST keeps existing instances (#175).
1869
+ */
1870
+ var nodeKeys = /* @__PURE__ */ new WeakMap();
1871
+ var nextNodeKey = 0;
1872
+ function nodeKey(node) {
1873
+ let key = nodeKeys.get(node);
1874
+ if (key === void 0) {
1875
+ key = `n${nextNodeKey++}`;
1876
+ nodeKeys.set(node, key);
1877
+ }
1878
+ return key;
1879
+ }
1738
1880
  function renderMacro(node, key) {
1739
1881
  if (isSubMacro(node.name)) return null;
1740
1882
  const widget = getWidget(node.name);
@@ -1760,9 +1902,10 @@ function renderMacro(node, key) {
1760
1902
  /**
1761
1903
  * Render a non-text AST node to a Preact element.
1762
1904
  */
1763
- function renderSingleNode(node, key) {
1905
+ function renderSingleNode(node) {
1906
+ if (node.type === "text") return node.value;
1907
+ const key = nodeKey(node);
1764
1908
  switch (node.type) {
1765
- case "text": return node.value;
1766
1909
  case "variable":
1767
1910
  if (node.scope === "local" && node.name === "children") return /* @__PURE__ */ jsx(ChildrenSlot, {}, key);
1768
1911
  return /* @__PURE__ */ jsx(VarDisplay, {
@@ -1788,7 +1931,7 @@ function renderSingleNode(node, key) {
1788
1931
  */
1789
1932
  function renderInlineNodes(nodes) {
1790
1933
  if (nodes.length === 0) return null;
1791
- return nodes.map((node, i) => renderSingleNode(node, i));
1934
+ return nodes.map((node) => renderSingleNode(node));
1792
1935
  }
1793
1936
  function hasUnclosedBacktick(s) {
1794
1937
  let count = 0;
@@ -1814,8 +1957,10 @@ function getVariableTextValue(node, locals) {
1814
1957
  * Characters/patterns that trigger CommonMark or GFM transformations.
1815
1958
  * Any match → fall through to the full micromark pipeline.
1816
1959
  * False positives (e.g. `-` used as text, not list) just use the slower path.
1960
+ * Includes character references (`&amp;`, `&#123;`) and two-space hard line
1961
+ * breaks, which micromark decodes / turns into <br> (#171).
1817
1962
  */
1818
- var MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\./;
1963
+ var MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
1819
1964
  var BLANK_LINE_RE = /\n\s*\n/;
1820
1965
  var PLACEHOLDER_SPLIT_RE = /(<span data-tw="\d+"><\/span>)/;
1821
1966
  var PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
@@ -1847,7 +1992,7 @@ function buildPlainTextVnodes(combined, components, nobr) {
1847
1992
  */
1848
1993
  function renderNodes(nodes, options) {
1849
1994
  if (nodes.length === 0) return null;
1850
- if (!nodes.some((n) => n.type === "text" && (n.value.trim() !== "" || /\n\s*\n/.test(n.value)))) return nodes.map((node, i) => renderSingleNode(node, i));
1995
+ if (!nodes.some((n) => n.type === "text" && (n.value.trim() !== "" || /\n\s*\n/.test(n.value)))) return nodes.map((node) => renderSingleNode(node));
1851
1996
  const components = [];
1852
1997
  let combined = "";
1853
1998
  const locals = options?.locals ?? {};
@@ -1857,7 +2002,7 @@ function renderNodes(nodes, options) {
1857
2002
  else if (node.type === "variable" && hasUnclosedBacktick(combined)) combined += getVariableTextValue(node, locals);
1858
2003
  else {
1859
2004
  const phIdx = components.length;
1860
- components.push(renderSingleNode(node, i));
2005
+ components.push(renderSingleNode(node));
1861
2006
  combined += `<span data-tw="${phIdx}"></span>`;
1862
2007
  }
1863
2008
  }
@@ -2374,6 +2519,7 @@ function deserialize(value) {
2374
2519
  for (const key of Object.keys(data)) instance[key] = deser(data[key]);
2375
2520
  return instance;
2376
2521
  }
2522
+ if (val instanceof Date || val instanceof RegExp || val instanceof Map || val instanceof Set) return val;
2377
2523
  const ctor = obj.constructor;
2378
2524
  if (ctorToName.has(ctor)) return val;
2379
2525
  const result = {};
@@ -2383,6 +2529,54 @@ function deserialize(value) {
2383
2529
  return deser(value);
2384
2530
  }
2385
2531
  //#endregion
2532
+ //#region src/event-emitter.ts
2533
+ var VALID_EVENTS = new Set([
2534
+ "storyinit",
2535
+ "beforerestart",
2536
+ "actionsChanged",
2537
+ "variableChanged",
2538
+ "beforesave",
2539
+ "aftersave",
2540
+ "beforeload",
2541
+ "afterload",
2542
+ "beforenavigate",
2543
+ "afternavigate",
2544
+ "passagerender",
2545
+ "dialogrender"
2546
+ ]);
2547
+ var listeners = /* @__PURE__ */ new Map();
2548
+ function on(event, cb) {
2549
+ if (!VALID_EVENTS.has(event)) throw new Error(`spindle: Unknown event "${event}".`);
2550
+ let set = listeners.get(event);
2551
+ if (!set) {
2552
+ set = /* @__PURE__ */ new Set();
2553
+ listeners.set(event, set);
2554
+ }
2555
+ set.add(cb);
2556
+ return () => {
2557
+ set.delete(cb);
2558
+ };
2559
+ }
2560
+ function emit(event, ...args) {
2561
+ const set = listeners.get(event);
2562
+ if (!set) return;
2563
+ for (const cb of [...set]) cb(...args);
2564
+ }
2565
+ /**
2566
+ * Emit an event from inside a render commit (layout effect). A throwing
2567
+ * handler is logged instead of propagating into Preact and breaking the
2568
+ * render; the remaining handlers still run.
2569
+ */
2570
+ function emitFromRender(event, ...args) {
2571
+ const set = listeners.get(event);
2572
+ if (!set) return;
2573
+ for (const cb of [...set]) try {
2574
+ cb(...args);
2575
+ } catch (err) {
2576
+ console.error(`spindle: Error in ${event} handler:`, err);
2577
+ }
2578
+ }
2579
+ //#endregion
2386
2580
  //#region src/saves/save-manager.ts
2387
2581
  var titleGenerator = null;
2388
2582
  var saveTitlePassageContent = null;
@@ -2429,6 +2623,19 @@ async function startNewPlaythrough(ifid) {
2429
2623
  async function getCurrentPlaythroughId(ifid) {
2430
2624
  return (await getBackend()).getMeta(`currentPlaythroughId.${ifid}`);
2431
2625
  }
2626
+ /**
2627
+ * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
2628
+ * before `getPayload()` captures the state, so data a hook sets is part of the
2629
+ * save; `aftersave` fires once `write` has stored it. Every user-facing save
2630
+ * path goes through here.
2631
+ */
2632
+ function saveWithHooks(slot, custom, getPayload, write) {
2633
+ emit("beforesave", slot, custom);
2634
+ return write(getPayload()).then((result) => {
2635
+ emit("aftersave", slot);
2636
+ return result;
2637
+ });
2638
+ }
2432
2639
  async function createSave(ifid, playthroughId, payload, custom = {}) {
2433
2640
  const now = (/* @__PURE__ */ new Date()).toISOString();
2434
2641
  const meta = {
@@ -2481,16 +2688,25 @@ async function overwriteSave(saveId, payload, custom) {
2481
2688
  await backend.putSave(updated);
2482
2689
  return updated;
2483
2690
  }
2691
+ /**
2692
+ * Turn a stored (serialized) payload into a live one, restoring class
2693
+ * instances and built-ins. Returns a new payload; the stored one is left
2694
+ * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
2695
+ */
2696
+ function deserializePayload(payload) {
2697
+ return {
2698
+ ...payload,
2699
+ variables: deserialize(payload.variables),
2700
+ history: payload.history.map((m) => ({
2701
+ ...m,
2702
+ variables: deserialize(m.variables)
2703
+ }))
2704
+ };
2705
+ }
2484
2706
  async function loadSave(saveId) {
2485
2707
  const record = await (await getBackend()).getSave(saveId);
2486
2708
  if (!record) return void 0;
2487
- const payload = record.payload;
2488
- payload.variables = deserialize(payload.variables);
2489
- payload.history = payload.history.map((m) => ({
2490
- ...m,
2491
- variables: deserialize(m.variables)
2492
- }));
2493
- return payload;
2709
+ return deserializePayload(record.payload);
2494
2710
  }
2495
2711
  async function deleteSaveById(saveId) {
2496
2712
  await (await getBackend()).deleteSave(saveId);
@@ -2545,20 +2761,51 @@ async function getSavesGrouped(ifid) {
2545
2761
  var AUTOSAVE_KEY_PREFIX = "autosave.";
2546
2762
  var SLOT_KEY_PREFIX = "slot.";
2547
2763
  var SLOT_INDEX_KEY_PREFIX = "slotIndex.";
2764
+ /**
2765
+ * Whether `slot` names a slot. The default (autosave) slot is addressed by
2766
+ * omitting `slot` or by `''`, the identifier its SaveInfo reports.
2767
+ */
2768
+ function isNamedSlot(slot) {
2769
+ return slot != null && slot !== "";
2770
+ }
2548
2771
  function slotMetaKey(ifid, slot) {
2549
- return slot != null ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
2772
+ return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
2773
+ }
2774
+ /** Tail of the queue that serializes slot index updates. */
2775
+ var slotIndexQueue = Promise.resolve();
2776
+ /**
2777
+ * Read-modify-write the per-story slot index. Updates are queued so that
2778
+ * concurrent saves, imports and deletes can't overwrite each other's change.
2779
+ */
2780
+ function updateSlotIndex(ifid, update) {
2781
+ const run = slotIndexQueue.then(async () => {
2782
+ const backend = await getBackend();
2783
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
2784
+ const existing = await backend.getMeta(indexKey) ?? [];
2785
+ const updated = update(existing);
2786
+ if (updated !== existing) await backend.setMeta(indexKey, updated);
2787
+ });
2788
+ slotIndexQueue = run.catch(() => {});
2789
+ return run;
2550
2790
  }
2551
2791
  /** Record a named slot in the per-story slot index (no-op for the default slot). */
2552
- async function addToSlotIndex(ifid, slot) {
2553
- if (slot == null) return;
2554
- const backend = await getBackend();
2792
+ function addToSlotIndex(ifid, slot) {
2793
+ if (!isNamedSlot(slot)) return Promise.resolve();
2794
+ return updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
2795
+ }
2796
+ /** Remove a named slot from the per-story slot index (no-op for the default slot). */
2797
+ function removeFromSlotIndex(ifid, slot) {
2798
+ if (!isNamedSlot(slot)) return Promise.resolve();
2799
+ return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
2800
+ }
2801
+ /** Named slots in the per-story slot index. */
2802
+ async function getIndexedSlots(ifid) {
2555
2803
  const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
2556
- const existing = await backend.getMeta(indexKey) ?? [];
2557
- if (!existing.includes(slot)) await backend.setMeta(indexKey, [...existing, slot]);
2804
+ return (await (await getBackend()).getMeta(indexKey) ?? []).filter(isNamedSlot);
2558
2805
  }
2559
2806
  function toSaveInfo(record, slot) {
2560
2807
  return {
2561
- slot: slot ?? "",
2808
+ slot: isNamedSlot(slot) ? slot : "",
2562
2809
  title: record.meta.title,
2563
2810
  passage: record.meta.passage,
2564
2811
  createdAt: record.meta.createdAt,
@@ -2575,8 +2822,8 @@ async function quickSave(ifid, playthroughId, payload, slot, custom) {
2575
2822
  if (updated) return updated;
2576
2823
  }
2577
2824
  const record = await createSave(ifid, playthroughId, payload, {
2578
- isAutosave: !slot,
2579
- ...slot != null ? { slot } : {},
2825
+ isAutosave: !isNamedSlot(slot),
2826
+ ...isNamedSlot(slot) ? { slot } : {},
2580
2827
  ...custom
2581
2828
  });
2582
2829
  await backend.setMeta(metaKey, record.meta.id);
@@ -2603,9 +2850,7 @@ async function loadQuickSave(ifid, slot) {
2603
2850
  async function populateKnownSaves(ifid) {
2604
2851
  const result = {};
2605
2852
  if (await hasQuickSave(ifid)) result[""] = true;
2606
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
2607
- const slots = await (await getBackend()).getMeta(indexKey) ?? [];
2608
- for (const slot of slots) if (await hasQuickSave(ifid, slot)) result[slot] = true;
2853
+ for (const slot of await getIndexedSlots(ifid)) if (await hasQuickSave(ifid, slot)) result[slot] = true;
2609
2854
  return result;
2610
2855
  }
2611
2856
  /**
@@ -2628,9 +2873,7 @@ async function listSlotSaves(ifid) {
2628
2873
  const result = [];
2629
2874
  const defaultInfo = await getSlotSaveInfo(ifid);
2630
2875
  if (defaultInfo) result.push(defaultInfo);
2631
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
2632
- const slots = await (await getBackend()).getMeta(indexKey) ?? [];
2633
- for (const slot of slots) {
2876
+ for (const slot of await getIndexedSlots(ifid)) {
2634
2877
  const info = await getSlotSaveInfo(ifid, slot);
2635
2878
  if (info) result.push(info);
2636
2879
  }
@@ -2646,11 +2889,7 @@ async function deleteSlotSave(ifid, slot) {
2646
2889
  if (!existingId) return;
2647
2890
  await backend.deleteSave(existingId);
2648
2891
  await backend.deleteMeta(metaKey);
2649
- if (slot != null) {
2650
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
2651
- const updated = (await backend.getMeta(indexKey) ?? []).filter((s) => s !== slot);
2652
- await backend.setMeta(indexKey, updated);
2653
- }
2892
+ await removeFromSlotIndex(ifid, slot);
2654
2893
  }
2655
2894
  var SESSION_KEY_PREFIX = "spindle.session.";
2656
2895
  /**
@@ -2668,13 +2907,7 @@ function loadSession(ifid) {
2668
2907
  if (!raw) return void 0;
2669
2908
  const parsed = JSON.parse(raw);
2670
2909
  if (!isSavePayload(parsed)) return void 0;
2671
- const payload = parsed;
2672
- payload.variables = deserialize(payload.variables);
2673
- payload.history = payload.history.map((m) => ({
2674
- ...m,
2675
- variables: deserialize(m.variables)
2676
- }));
2677
- return payload;
2910
+ return deserializePayload(parsed);
2678
2911
  } catch {
2679
2912
  return;
2680
2913
  }
@@ -2742,8 +2975,8 @@ async function importSlotSave(data, ifid, slot) {
2742
2975
  const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
2743
2976
  record.meta.custom = {
2744
2977
  ...custom,
2745
- isAutosave: !slot,
2746
- ...slot != null ? { slot } : {}
2978
+ isAutosave: !isNamedSlot(slot),
2979
+ ...isNamedSlot(slot) ? { slot } : {}
2747
2980
  };
2748
2981
  const backend = await getBackend();
2749
2982
  const metaKey = slotMetaKey(ifid, slot);
@@ -2807,12 +3040,26 @@ async function deletePlaythroughData(ifid, playthroughId) {
2807
3040
  }
2808
3041
  //#endregion
2809
3042
  //#region src/story-init.ts
3043
+ /** Hidden container holding the currently mounted StoryInit tree. */
3044
+ var storyInitContainer = null;
3045
+ /**
3046
+ * Unmount the current StoryInit tree (running its effect cleanups, e.g.
3047
+ * pending {timed}/{repeat} timers) and remove its container.
3048
+ */
3049
+ function unmountStoryInit() {
3050
+ if (!storyInitContainer) return;
3051
+ render(null, storyInitContainer);
3052
+ storyInitContainer.remove();
3053
+ storyInitContainer = null;
3054
+ }
2810
3055
  /**
2811
3056
  * Execute the StoryInit passage: tokenize, parse, and render all macros
2812
3057
  * into a detached DOM node so their side effects fire through the normal
2813
3058
  * Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
3059
+ * Re-executing (on restart) first unmounts the previous StoryInit tree.
2814
3060
  */
2815
3061
  function executeStoryInit() {
3062
+ unmountStoryInit();
2816
3063
  const state = useStoryStore.getState();
2817
3064
  if (!state.storyData) return;
2818
3065
  const storyInit = state.storyData.passages.get("StoryInit");
@@ -2821,58 +3068,67 @@ function executeStoryInit() {
2821
3068
  const container = document.createElement("div");
2822
3069
  container.style.display = "none";
2823
3070
  document.body.appendChild(container);
3071
+ storyInitContainer = container;
2824
3072
  render(h(() => renderNodes(ast), null), container);
3073
+ recordStoryInitState();
2825
3074
  }
2826
3075
  const saveTitlePassage = state.storyData.passages.get("SaveTitle");
2827
3076
  if (saveTitlePassage) setSaveTitlePassage(saveTitlePassage.content);
2828
3077
  }
2829
3078
  //#endregion
2830
- //#region src/event-emitter.ts
2831
- var VALID_EVENTS = new Set([
2832
- "storyinit",
2833
- "beforerestart",
2834
- "actionsChanged",
2835
- "variableChanged",
2836
- "beforesave",
2837
- "aftersave",
2838
- "beforeload",
2839
- "afterload",
2840
- "beforenavigate",
2841
- "afternavigate",
2842
- "passagerender",
2843
- "dialogrender"
2844
- ]);
2845
- var listeners = /* @__PURE__ */ new Map();
2846
- function on(event, cb) {
2847
- if (!VALID_EVENTS.has(event)) throw new Error(`spindle: Unknown event "${event}".`);
2848
- let set = listeners.get(event);
2849
- if (!set) {
2850
- set = /* @__PURE__ */ new Set();
2851
- listeners.set(event, set);
3079
+ //#region src/execute-mutation.ts
3080
+ /**
3081
+ * Structural equality for a deepClone() copy against its source. Lets
3082
+ * executeMutation write back only the variables the code actually changed,
3083
+ * so untouched clones don't overwrite updates made during execution
3084
+ * (e.g. Story.set called from inside {do}).
3085
+ */
3086
+ function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
3087
+ if (Object.is(a, b)) return true;
3088
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
3089
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
3090
+ if (seen.get(a) === b) return true;
3091
+ seen.set(a, b);
3092
+ if (a instanceof Date) return a.getTime() === b.getTime();
3093
+ if (a instanceof RegExp) return String(a) === String(b);
3094
+ if (a instanceof Map || a instanceof Set) {
3095
+ const bc = b;
3096
+ if (a.size !== bc.size) return false;
3097
+ const ai = a.entries();
3098
+ const bi = bc.entries();
3099
+ for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!deepEqual(x.value, y.value, seen)) return false;
3100
+ return true;
2852
3101
  }
2853
- set.add(cb);
2854
- return () => {
2855
- set.delete(cb);
2856
- };
2857
- }
2858
- function emit(event, ...args) {
2859
- const set = listeners.get(event);
2860
- if (!set) return;
2861
- for (const cb of [...set]) cb(...args);
3102
+ const ao = a;
3103
+ const bo = b;
3104
+ const keys = Object.keys(ao);
3105
+ if (keys.length !== Object.keys(bo).length) return false;
3106
+ for (const key of keys) if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
3107
+ return true;
2862
3108
  }
2863
3109
  /**
2864
- * Emit an event from inside a render commit (layout effect). A throwing
2865
- * handler is logged instead of propagating into Preact and breaking the
2866
- * render; the remaining handlers still run.
3110
+ * Write the keys `code` changed (`clone` vs the pre-execution `before`) into
3111
+ * an Immer draft of the same namespace, and delete the keys it removed.
3112
+ * Untouched keys are left alone, so updates made during execution survive.
2867
3113
  */
2868
- function emitFromRender(event, ...args) {
2869
- const set = listeners.get(event);
2870
- if (!set) return;
2871
- for (const cb of [...set]) try {
2872
- cb(...args);
2873
- } catch (err) {
2874
- console.error(`spindle: Error in ${event} handler:`, err);
2875
- }
3114
+ function commitNamespace(draft, clone, before) {
3115
+ for (const key of Object.keys(clone)) if (!deepEqual(clone[key], before[key])) draft[key] = clone[key];
3116
+ for (const key of Object.keys(before)) if (!(key in clone)) delete draft[key];
3117
+ }
3118
+ function executeMutation(code, mergedLocals, scopeUpdate) {
3119
+ const state = useStoryStore.getState();
3120
+ const vars = deepClone(state.variables);
3121
+ const temps = deepClone(state.temporary);
3122
+ const trans = deepClone(state.transient);
3123
+ const localsClone = { ...mergedLocals };
3124
+ execute(code, vars, temps, localsClone, trans);
3125
+ state.updateVariables((draft) => {
3126
+ commitNamespace(draft.variables, vars, state.variables);
3127
+ commitNamespace(draft.temporary, temps, state.temporary);
3128
+ commitNamespace(draft.transient, trans, state.transient);
3129
+ });
3130
+ for (const key of Object.keys(localsClone)) if (localsClone[key] !== mergedLocals[key]) scopeUpdate(key, localsClone[key]);
3131
+ for (const key of Object.keys(mergedLocals)) if (!(key in localsClone)) scopeUpdate(key, void 0);
2876
3132
  }
2877
3133
  //#endregion
2878
3134
  //#region src/triggers.ts
@@ -2890,7 +3146,7 @@ function evalCondition(condition) {
2890
3146
  return false;
2891
3147
  }
2892
3148
  }
2893
- function addTrigger(condition, callbackOrOptions) {
3149
+ function registerTrigger(condition, callbackOrOptions, macroKey) {
2894
3150
  const id = nextId++;
2895
3151
  const isCallback = typeof callbackOrOptions === "function";
2896
3152
  const options = isCallback ? void 0 : callbackOrOptions;
@@ -2902,7 +3158,8 @@ function addTrigger(condition, callbackOrOptions) {
2902
3158
  callback,
2903
3159
  options,
2904
3160
  lastResult: evalCondition(condition),
2905
- priority: options?.priority ?? 0
3161
+ priority: options?.priority ?? 0,
3162
+ macroKey
2906
3163
  };
2907
3164
  triggers.push(trigger);
2908
3165
  triggers.sort((a, b) => b.priority - a.priority);
@@ -2910,6 +3167,22 @@ function addTrigger(condition, callbackOrOptions) {
2910
3167
  triggers = triggers.filter((t) => t.id !== id);
2911
3168
  };
2912
3169
  }
3170
+ function addTrigger(condition, callbackOrOptions) {
3171
+ return registerTrigger(condition, callbackOrOptions);
3172
+ }
3173
+ /**
3174
+ * Register the watcher of a {watch} macro. Watchers outlive the passage
3175
+ * that declared them, and that passage mounts its macros again on every
3176
+ * visit (and on re-renders that remount them), so an identical watcher
3177
+ * (same condition and options) that is still registered is kept instead
3178
+ * of being added a second time.
3179
+ */
3180
+ function addMacroTrigger(condition, options) {
3181
+ const sortedOptions = Object.fromEntries(Object.entries(options).sort(([a], [b]) => a < b ? -1 : 1));
3182
+ const macroKey = JSON.stringify([condition, sortedOptions]);
3183
+ if (triggers.some((t) => t.macroKey === macroKey)) return;
3184
+ registerTrigger(condition, options, macroKey);
3185
+ }
2913
3186
  function removeTrigger(name) {
2914
3187
  triggers = triggers.filter((t) => t.name !== name);
2915
3188
  }
@@ -2920,9 +3193,10 @@ function fireTrigger(trigger) {
2920
3193
  return;
2921
3194
  }
2922
3195
  if (!options) return;
2923
- if (options.run) {
2924
- const state = useStoryStore.getState();
2925
- execute(options.run, state.variables, state.temporary);
3196
+ if (options.run) try {
3197
+ executeMutation(options.run, {}, () => {});
3198
+ } catch (err) {
3199
+ console.error(`spindle: Error in watch run action for "${trigger.condition}":`, err);
2926
3200
  }
2927
3201
  if (options.dialog) {
2928
3202
  dialogQueue.push({ passageName: options.dialog });
@@ -2931,30 +3205,77 @@ function fireTrigger(trigger) {
2931
3205
  if (options.goto) useStoryStore.getState().navigate(options.goto);
2932
3206
  }
2933
3207
  var MAX_RECHECK_DEPTH = 10;
3208
+ function runCheckLoop() {
3209
+ for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
3210
+ let anyFired = false;
3211
+ const current = [...triggers];
3212
+ for (const trigger of current) {
3213
+ if (!triggers.includes(trigger)) continue;
3214
+ const result = evalCondition(trigger.condition);
3215
+ const wasFalse = !trigger.lastResult;
3216
+ trigger.lastResult = result;
3217
+ if (result && wasFalse) {
3218
+ anyFired = true;
3219
+ if (trigger.options?.once) triggers = triggers.filter((t) => t.id !== trigger.id);
3220
+ fireTrigger(trigger);
3221
+ }
3222
+ }
3223
+ if (!anyFired) break;
3224
+ }
3225
+ }
2934
3226
  function checkTriggers() {
2935
3227
  if (checking) return;
2936
3228
  checking = true;
2937
3229
  try {
2938
- for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
2939
- let anyFired = false;
2940
- const current = [...triggers];
2941
- for (const trigger of current) {
2942
- if (!triggers.includes(trigger)) continue;
2943
- const result = evalCondition(trigger.condition);
2944
- const wasFalse = !trigger.lastResult;
2945
- trigger.lastResult = result;
2946
- if (result && wasFalse) {
2947
- anyFired = true;
2948
- if (trigger.options?.once) triggers = triggers.filter((t) => t.id !== trigger.id);
2949
- fireTrigger(trigger);
2950
- }
2951
- }
2952
- if (!anyFired) break;
2953
- }
3230
+ runCheckLoop();
2954
3231
  } finally {
2955
3232
  checking = false;
2956
3233
  }
2957
3234
  }
3235
+ /** Number of live connectTriggersToStore() subscriptions. */
3236
+ var connections = 0;
3237
+ /**
3238
+ * Check watchers against a navigation that navigate() has just completed.
3239
+ * Called by the store before it records the entered moment, so run actions
3240
+ * become part of that moment. Runs even when the navigation itself came
3241
+ * from a watcher (a check already in progress), since that check started
3242
+ * before the navigation and cannot see it.
3243
+ */
3244
+ function checkTriggersOnNavigation() {
3245
+ if (connections === 0) return;
3246
+ const wasChecking = checking;
3247
+ checking = true;
3248
+ try {
3249
+ runCheckLoop();
3250
+ } finally {
3251
+ checking = wasChecking;
3252
+ }
3253
+ }
3254
+ /**
3255
+ * Re-evaluate watchers whenever a namespace their conditions can read
3256
+ * ($variables, _temporary, %transient) changes. Navigation is left to the
3257
+ * store: navigate() checks watchers once the new moment is complete
3258
+ * (checkTriggersOnNavigation), while history traversal and loads
3259
+ * reinitialize watcher state instead of firing.
3260
+ */
3261
+ function connectTriggersToStore() {
3262
+ let prev = useStoryStore.getState();
3263
+ connections++;
3264
+ const unsubscribe = useStoryStore.subscribe((state) => {
3265
+ const before = prev;
3266
+ prev = state;
3267
+ if (state.navigationId !== before.navigationId) return;
3268
+ if (state.variables === before.variables && state.temporary === before.temporary && state.transient === before.transient) return;
3269
+ checkTriggers();
3270
+ });
3271
+ let connected = true;
3272
+ return () => {
3273
+ if (!connected) return;
3274
+ connected = false;
3275
+ connections--;
3276
+ unsubscribe();
3277
+ };
3278
+ }
2958
3279
  function reinitTriggerState() {
2959
3280
  for (const trigger of triggers) trigger.lastResult = evalCondition(trigger.condition);
2960
3281
  }
@@ -3047,6 +3368,15 @@ function computeVarPatches(prev, curr) {
3047
3368
  inverse: clonePatches(inverse)
3048
3369
  };
3049
3370
  }
3371
+ /**
3372
+ * Replace the variable snapshot recorded for the newest history moment
3373
+ * (e.g. with changes made by watchers reacting to the navigation into it).
3374
+ */
3375
+ function rerecordNewestMoment(vars) {
3376
+ const last = patchEntries.length - 1;
3377
+ if (last < 0) variableBase = vars;
3378
+ else patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
3379
+ }
3050
3380
  /** Reconstruct variables at a given history moment by replaying patches. */
3051
3381
  function reconstructVarsAt(index) {
3052
3382
  let vars = variableBase;
@@ -3070,15 +3400,12 @@ function persistSession(get) {
3070
3400
  let vars = variableBase;
3071
3401
  for (let i = 0; i < history.length; i++) {
3072
3402
  if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
3073
- if (i >= serializedHistory.length) {
3074
- const v = i === history.length - 1 ? variables : vars;
3075
- serializedHistory[i] = {
3076
- passage: history[i].passage,
3077
- variables: serialize(v),
3078
- timestamp: history[i].timestamp,
3079
- prng: history[i].prng
3080
- };
3081
- }
3403
+ if (i >= serializedHistory.length) serializedHistory[i] = {
3404
+ passage: history[i].passage,
3405
+ variables: serialize(vars),
3406
+ timestamp: history[i].timestamp,
3407
+ prng: history[i].prng
3408
+ };
3082
3409
  }
3083
3410
  }
3084
3411
  saveSession(storyData.ifid, {
@@ -3091,6 +3418,10 @@ function persistSession(get) {
3091
3418
  prng: snapshotPRNG()
3092
3419
  });
3093
3420
  }
3421
+ /** True while navigate() lets watchers react to the moment it entered. */
3422
+ var navigationTriggerPhase = false;
3423
+ /** Navigations requested during the trigger phase, run once it is over. */
3424
+ var deferredNavigations = [];
3094
3425
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
3095
3426
  function resetModuleState(base) {
3096
3427
  variableBase = base;
@@ -3098,6 +3429,24 @@ function resetModuleState(base) {
3098
3429
  lastNavigationVars = base;
3099
3430
  serializedHistory = [];
3100
3431
  }
3432
+ /**
3433
+ * Record the state StoryInit left behind as the start moment: its variable
3434
+ * snapshot (the history base) and PRNG state. Called by executeStoryInit()
3435
+ * after the StoryInit passage has rendered; init()/restart() record the
3436
+ * start moment before StoryInit runs. A no-op once the story has moved on.
3437
+ */
3438
+ function recordStoryInitState() {
3439
+ const { history, historyIndex, variables } = useStoryStore.getState();
3440
+ if (history.length !== 1 || historyIndex !== 0) return;
3441
+ variableBase = variables;
3442
+ patchEntries = [];
3443
+ lastNavigationVars = variables;
3444
+ serializedHistory = [];
3445
+ const prng = snapshotPRNG();
3446
+ useStoryStore.setState((state) => {
3447
+ state.history[0].prng = prng;
3448
+ });
3449
+ }
3101
3450
  var runtimeUnsubs = [];
3102
3451
  var inRuntimePhase = false;
3103
3452
  /**
@@ -3121,14 +3470,39 @@ function cleanupRuntimeHandlers() {
3121
3470
  function normalizeHotkey(key) {
3122
3471
  return typeof key === "string" && key !== "" ? key : null;
3123
3472
  }
3473
+ /**
3474
+ * The history moment whose entry snapshot a load restores: the moment at
3475
+ * `historyIndex`, provided it is in range and belongs to the saved passage.
3476
+ * Otherwise (malformed or foreign payloads) the load falls back to the
3477
+ * payload's live variables.
3478
+ */
3479
+ function loadedEntryMoment(payload) {
3480
+ const { history, historyIndex } = payload;
3481
+ if (!Number.isInteger(historyIndex)) return void 0;
3482
+ const moment = history[historyIndex];
3483
+ if (!moment || moment.passage !== payload.passage) return void 0;
3484
+ if (typeof moment.variables !== "object" || moment.variables === null) return;
3485
+ return moment;
3486
+ }
3124
3487
  /** Restore or reset PRNG from a history moment's snapshot. */
3125
3488
  function restorePRNGFromMoment(moment) {
3126
3489
  if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
3127
3490
  else if (moment) resetPRNG();
3128
3491
  }
3492
+ /**
3493
+ * Return `p` marked as handled: a caller that ignores the result of a
3494
+ * fire-and-forget operation (whose failure is already logged) gets no
3495
+ * unhandled-rejection report, while a caller that awaits it still sees the
3496
+ * error.
3497
+ */
3498
+ function handled(p) {
3499
+ p.catch(() => {});
3500
+ return p;
3501
+ }
3129
3502
  var useStoryStore = create()(immer((set, get) => ({
3130
3503
  storyData: null,
3131
3504
  currentPassage: "",
3505
+ navigationId: 0,
3132
3506
  variables: {},
3133
3507
  variableDefaults: {},
3134
3508
  transient: {},
@@ -3172,6 +3546,7 @@ var useStoryStore = create()(immer((set, get) => ({
3172
3546
  set((state) => {
3173
3547
  state.storyData = storyData;
3174
3548
  state.currentPassage = startPassage.name;
3549
+ state.navigationId++;
3175
3550
  state.variables = initialVars;
3176
3551
  state.variableDefaults = variableDefaults;
3177
3552
  state.transient = deepClone(transientDefaults);
@@ -3205,7 +3580,11 @@ var useStoryStore = create()(immer((set, get) => ({
3205
3580
  }).catch((err) => console.error("spindle: failed to init save system", err));
3206
3581
  },
3207
3582
  navigate: (passageName) => {
3208
- const { storyData, variables: currVars } = get();
3583
+ if (navigationTriggerPhase) {
3584
+ deferredNavigations.push(passageName);
3585
+ return;
3586
+ }
3587
+ const { storyData } = get();
3209
3588
  if (!storyData) return;
3210
3589
  if (SPECIAL_PASSAGES.has(passageName)) {
3211
3590
  console.error(`spindle: Cannot navigate to special passage "${passageName}".`);
@@ -3217,12 +3596,14 @@ var useStoryStore = create()(immer((set, get) => ({
3217
3596
  }
3218
3597
  const previousPassage = get().currentPassage;
3219
3598
  emit("beforenavigate", passageName);
3220
- const patchEntry = computeVarPatches(lastNavigationVars, currVars);
3599
+ const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
3221
3600
  set((state) => {
3222
3601
  state.temporary = {};
3223
3602
  state.currentPassage = passageName;
3603
+ state.navigationId++;
3224
3604
  state.history = state.history.slice(0, state.historyIndex + 1);
3225
3605
  patchEntries.length = state.historyIndex;
3606
+ if (serializedHistory.length > state.historyIndex + 1) serializedHistory.length = state.historyIndex + 1;
3226
3607
  patchEntries.push(patchEntry);
3227
3608
  state.history.push({
3228
3609
  passage: passageName,
@@ -3240,41 +3621,61 @@ var useStoryStore = create()(immer((set, get) => ({
3240
3621
  state.visitCounts[passageName] = (state.visitCounts[passageName] ?? 0) + 1;
3241
3622
  state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
3242
3623
  });
3624
+ const enteredVars = get().variables;
3625
+ navigationTriggerPhase = true;
3626
+ try {
3627
+ checkTriggersOnNavigation();
3628
+ } finally {
3629
+ navigationTriggerPhase = false;
3630
+ }
3631
+ const deferred = deferredNavigations;
3632
+ deferredNavigations = [];
3633
+ if (get().variables !== enteredVars) rerecordNewestMoment(get().variables);
3634
+ const prng = snapshotPRNG();
3635
+ const recorded = get().history[get().historyIndex].prng;
3636
+ if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) set((state) => {
3637
+ state.history[state.historyIndex].prng = prng;
3638
+ });
3243
3639
  lastNavigationVars = get().variables;
3244
3640
  persistSession(get);
3245
3641
  emit("afternavigate", passageName, previousPassage);
3642
+ for (const next of deferred) get().navigate(next);
3246
3643
  },
3247
3644
  goBack: () => {
3248
- const { historyIndex, variables } = get();
3645
+ const { historyIndex } = get();
3249
3646
  if (historyIndex <= 0) return;
3250
3647
  const previousPassage = get().currentPassage;
3251
3648
  const targetPassage = get().history[historyIndex - 1].passage;
3252
3649
  emit("beforenavigate", targetPassage);
3253
- const restoredVars = deepClone(applyPatches(variables, patchEntries[historyIndex - 1].inverse));
3650
+ const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
3254
3651
  set((state) => {
3255
3652
  state.historyIndex--;
3256
3653
  state.currentPassage = state.history[state.historyIndex].passage;
3654
+ state.navigationId++;
3257
3655
  state.variables = restoredVars;
3258
3656
  state.temporary = {};
3259
3657
  });
3658
+ reinitTriggerState();
3260
3659
  lastNavigationVars = get().variables;
3261
3660
  restorePRNGFromMoment(get().history[get().historyIndex]);
3262
3661
  persistSession(get);
3263
3662
  emit("afternavigate", targetPassage, previousPassage);
3264
3663
  },
3265
3664
  goForward: () => {
3266
- const { historyIndex, history: hist, variables } = get();
3665
+ const { historyIndex, history: hist } = get();
3267
3666
  if (historyIndex >= hist.length - 1) return;
3268
3667
  const previousPassage = get().currentPassage;
3269
3668
  const targetPassage = hist[historyIndex + 1].passage;
3270
3669
  emit("beforenavigate", targetPassage);
3271
- const restoredVars = deepClone(applyPatches(variables, patchEntries[historyIndex].forward));
3670
+ const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
3272
3671
  set((state) => {
3273
3672
  state.historyIndex++;
3274
3673
  state.currentPassage = state.history[state.historyIndex].passage;
3674
+ state.navigationId++;
3275
3675
  state.variables = restoredVars;
3276
3676
  state.temporary = {};
3277
3677
  });
3678
+ reinitTriggerState();
3278
3679
  lastNavigationVars = get().variables;
3279
3680
  restorePRNGFromMoment(get().history[get().historyIndex]);
3280
3681
  persistSession(get);
@@ -3310,6 +3711,11 @@ var useStoryStore = create()(immer((set, get) => ({
3310
3711
  delete state.transient[name];
3311
3712
  });
3312
3713
  },
3714
+ updateVariables: (recipe) => {
3715
+ set((state) => {
3716
+ recipe(state);
3717
+ });
3718
+ },
3313
3719
  trackRender: (passageName) => {
3314
3720
  set((state) => {
3315
3721
  state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
@@ -3332,6 +3738,7 @@ var useStoryStore = create()(immer((set, get) => ({
3332
3738
  resetModuleState(deepClone(initialVars));
3333
3739
  set((state) => {
3334
3740
  state.currentPassage = startPassage.name;
3741
+ state.navigationId++;
3335
3742
  state.variables = initialVars;
3336
3743
  state.transient = deepClone(transientDefaults);
3337
3744
  state.temporary = {};
@@ -3357,42 +3764,42 @@ var useStoryStore = create()(immer((set, get) => ({
3357
3764
  },
3358
3765
  save: (slot, custom) => {
3359
3766
  const { storyData, playthroughId } = get();
3360
- if (!storyData) return;
3361
- emit("beforesave", slot, custom);
3362
- const payload = get().getSavePayload();
3363
- set((state) => {
3364
- state.saveError = null;
3365
- });
3366
- quickSave(storyData.ifid, playthroughId, payload, slot, custom).then(() => {
3767
+ if (!storyData) return Promise.resolve();
3768
+ return handled(saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
3769
+ set((state) => {
3770
+ state.saveError = null;
3771
+ });
3772
+ await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
3367
3773
  set((state) => {
3368
3774
  state.knownSaves = {
3369
3775
  ...state.knownSaves,
3370
3776
  [slot ?? ""]: true
3371
3777
  };
3372
3778
  });
3373
- emit("aftersave", slot);
3374
3779
  }).catch((err) => {
3375
3780
  console.error("spindle: failed to save", err);
3376
3781
  set((state) => {
3377
3782
  state.saveError = err instanceof Error ? err.message : "Failed to save";
3378
3783
  });
3379
- });
3784
+ throw err;
3785
+ }));
3380
3786
  },
3381
3787
  load: (slot) => {
3382
3788
  const { storyData } = get();
3383
- if (!storyData) return;
3789
+ if (!storyData) return Promise.resolve();
3384
3790
  set((state) => {
3385
3791
  state.loadError = null;
3386
3792
  });
3387
- loadQuickSave(storyData.ifid, slot).then((payload) => {
3793
+ return handled(loadQuickSave(storyData.ifid, slot).then((payload) => {
3388
3794
  if (!payload) return;
3389
- get().loadFromPayload(payload);
3795
+ get().loadFromPayload(payload, slot);
3390
3796
  }).catch((err) => {
3391
3797
  console.error("spindle: failed to load save", err);
3392
3798
  set((state) => {
3393
3799
  state.loadError = err instanceof Error ? err.message : "Failed to load";
3394
3800
  });
3395
- });
3801
+ throw err;
3802
+ }));
3396
3803
  },
3397
3804
  hasSave: (slot) => {
3398
3805
  const { storyData, knownSaves } = get();
@@ -3411,8 +3818,8 @@ var useStoryStore = create()(immer((set, get) => ({
3411
3818
  },
3412
3819
  deleteSave: (slot) => {
3413
3820
  const { storyData } = get();
3414
- if (!storyData) return;
3415
- deleteSlotSave(storyData.ifid, slot).then(() => {
3821
+ if (!storyData) return Promise.resolve();
3822
+ return handled(deleteSlotSave(storyData.ifid, slot).then(() => {
3416
3823
  set((state) => {
3417
3824
  const key = slot ?? "";
3418
3825
  const { [key]: _, ...rest } = state.knownSaves;
@@ -3420,7 +3827,8 @@ var useStoryStore = create()(immer((set, get) => ({
3420
3827
  });
3421
3828
  }).catch((err) => {
3422
3829
  console.error("spindle: failed to delete save", err);
3423
- });
3830
+ throw err;
3831
+ }));
3424
3832
  },
3425
3833
  exportSave: async (slot) => {
3426
3834
  const { storyData } = get();
@@ -3499,26 +3907,33 @@ var useStoryStore = create()(immer((set, get) => ({
3499
3907
  prng: snapshotPRNG()
3500
3908
  };
3501
3909
  },
3502
- loadFromPayload: (payload) => {
3910
+ loadFromPayload: (payload, slot) => {
3503
3911
  if (payload.history.length === 0) {
3504
3912
  console.warn("loadFromPayload: rejecting payload with empty history");
3505
3913
  return;
3506
3914
  }
3507
- emit("beforeload", void 0);
3508
- const base = deserialize(payload.history[0]?.variables ?? {});
3915
+ emit("beforeload", slot);
3916
+ const entry = loadedEntryMoment(payload);
3917
+ const base = deepClone(payload.history[0]?.variables ?? {});
3509
3918
  const newPatchEntries = [];
3510
3919
  let prevVars = base;
3511
3920
  for (let i = 1; i < payload.history.length; i++) {
3512
- const currVars = deserialize(payload.history[i].variables);
3921
+ const currVars = deepClone(payload.history[i].variables);
3513
3922
  newPatchEntries.push(computeVarPatches(prevVars, currVars));
3514
3923
  prevVars = currVars;
3515
3924
  }
3516
3925
  variableBase = deepClone(base);
3517
3926
  patchEntries = newPatchEntries;
3518
- serializedHistory = [];
3927
+ serializedHistory = payload.history.map((m) => ({
3928
+ passage: m.passage,
3929
+ variables: serialize(m.variables),
3930
+ timestamp: m.timestamp,
3931
+ prng: m.prng
3932
+ }));
3519
3933
  set((state) => {
3520
3934
  state.currentPassage = payload.passage;
3521
- state.variables = deserialize(payload.variables);
3935
+ state.navigationId++;
3936
+ state.variables = deepClone(entry?.variables ?? payload.variables);
3522
3937
  state.history = payload.history.map((m) => ({
3523
3938
  passage: m.passage,
3524
3939
  timestamp: m.timestamp,
@@ -3530,10 +3945,13 @@ var useStoryStore = create()(immer((set, get) => ({
3530
3945
  state.temporary = {};
3531
3946
  state.transient = deepClone(get().transientDefaults);
3532
3947
  });
3533
- lastNavigationVars = get().variables;
3534
- if (payload.prng) restorePRNG(payload.prng.seed, payload.prng.pull);
3948
+ reinitTriggerState();
3949
+ lastNavigationVars = reconstructVarsAt(get().historyIndex);
3950
+ const prng = entry?.prng !== void 0 ? entry.prng : payload.prng;
3951
+ if (prng) restorePRNG(prng.seed, prng.pull);
3535
3952
  else resetPRNG();
3536
- emit("afterload", void 0);
3953
+ persistSession(get);
3954
+ emit("afterload", slot);
3537
3955
  },
3538
3956
  getHistoryVariables: (index) => {
3539
3957
  return deepClone(reconstructVarsAt(index));
@@ -3873,6 +4291,7 @@ function parseStoryData() {
3873
4291
  var definitions = /* @__PURE__ */ new Map();
3874
4292
  var values = {};
3875
4293
  var storageLoaded = false;
4294
+ var unsubscribeStoryData = null;
3876
4295
  function storageKey() {
3877
4296
  return `spindle.${useStoryStore.getState().storyData?.ifid || "unknown"}.settings`;
3878
4297
  }
@@ -3881,6 +4300,15 @@ function persist() {
3881
4300
  }
3882
4301
  function loadFromStorage() {
3883
4302
  if (storageLoaded) return;
4303
+ if (!useStoryStore.getState().storyData?.ifid) {
4304
+ if (!unsubscribeStoryData) unsubscribeStoryData = useStoryStore.subscribe((state) => {
4305
+ if (!state.storyData?.ifid) return;
4306
+ unsubscribeStoryData?.();
4307
+ unsubscribeStoryData = null;
4308
+ loadFromStorage();
4309
+ });
4310
+ return;
4311
+ }
3884
4312
  storageLoaded = true;
3885
4313
  try {
3886
4314
  const raw = localStorage.getItem(storageKey());
@@ -3948,24 +4376,6 @@ var settings = {
3948
4376
  }
3949
4377
  };
3950
4378
  //#endregion
3951
- //#region src/execute-mutation.ts
3952
- function executeMutation(code, mergedLocals, scopeUpdate) {
3953
- const state = useStoryStore.getState();
3954
- const vars = deepClone(state.variables);
3955
- const temps = deepClone(state.temporary);
3956
- const trans = deepClone(state.transient);
3957
- const localsClone = { ...mergedLocals };
3958
- execute(code, vars, temps, localsClone, trans);
3959
- for (const key of Object.keys(vars)) if (vars[key] !== state.variables[key]) state.setVariable(key, vars[key]);
3960
- for (const key of Object.keys(temps)) if (temps[key] !== state.temporary[key]) state.setTemporary(key, temps[key]);
3961
- for (const key of Object.keys(trans)) if (trans[key] !== state.transient[key]) state.setTransient(key, trans[key]);
3962
- for (const key of Object.keys(localsClone)) if (localsClone[key] !== mergedLocals[key]) scopeUpdate(key, localsClone[key]);
3963
- for (const key of Object.keys(state.variables)) if (!(key in vars)) state.deleteVariable(key);
3964
- for (const key of Object.keys(state.temporary)) if (!(key in temps)) state.deleteTemporary(key);
3965
- for (const key of Object.keys(state.transient)) if (!(key in trans)) state.deleteTransient(key);
3966
- for (const key of Object.keys(mergedLocals)) if (!(key in localsClone)) scopeUpdate(key, void 0);
3967
- }
3968
- //#endregion
3969
4379
  //#region src/action-registry.ts
3970
4380
  var actions = /* @__PURE__ */ new Map();
3971
4381
  var idCounters = /* @__PURE__ */ new Map();
@@ -4304,19 +4714,12 @@ function warnIfUndeclared(isTransient, key) {
4304
4714
  warnedUndeclared.add(label);
4305
4715
  console.warn(`spindle: Story.set() wrote ${label}, which is not declared in ${isTransient ? "StoryTransients" : "StoryVariables"}. Passages cannot reference it; check the name or declare it.`);
4306
4716
  }
4307
- /** Set a single variable, resolving dot-paths if present. */
4308
- function setOne(name, value) {
4717
+ /** Set a single variable on an Immer draft, resolving dot-paths if present. */
4718
+ function setOne(draft, name, value) {
4309
4719
  const { isTransient, key } = parseName(name);
4310
- const namespace = isTransient ? "transient" : "variables";
4311
- warnIfUndeclared(isTransient, key);
4312
- if (key.includes(".")) useStoryStore.setState((state) => {
4313
- setByPath(state[namespace], key, value);
4314
- });
4315
- else {
4316
- const state = useStoryStore.getState();
4317
- if (isTransient) state.setTransient(key, value);
4318
- else state.setVariable(key, value);
4319
- }
4720
+ const namespace = isTransient ? draft.transient : draft.variables;
4721
+ if (key.includes(".")) setByPath(namespace, key, value);
4722
+ else namespace[key] = value;
4320
4723
  }
4321
4724
  function createStoryAPI() {
4322
4725
  return {
@@ -4326,8 +4729,15 @@ function createStoryAPI() {
4326
4729
  return key.includes(".") ? getByPath(store, key) : store[key];
4327
4730
  },
4328
4731
  set(nameOrVars, value) {
4329
- if (typeof nameOrVars === "string") setOne(nameOrVars, value);
4330
- else for (const [k, v] of Object.entries(nameOrVars)) setOne(k, v);
4732
+ const names = typeof nameOrVars === "string" ? [nameOrVars] : Object.keys(nameOrVars);
4733
+ for (const name of names) {
4734
+ const { isTransient, key } = parseName(name);
4735
+ warnIfUndeclared(isTransient, key);
4736
+ }
4737
+ useStoryStore.getState().updateVariables((draft) => {
4738
+ if (typeof nameOrVars === "string") setOne(draft, nameOrVars, value);
4739
+ else for (const [k, v] of Object.entries(nameOrVars)) setOne(draft, k, v);
4740
+ });
4331
4741
  },
4332
4742
  goto(passageName) {
4333
4743
  useStoryStore.getState().navigate(passageName);
@@ -4342,10 +4752,10 @@ function createStoryAPI() {
4342
4752
  useStoryStore.getState().restart();
4343
4753
  },
4344
4754
  save(slot, custom) {
4345
- useStoryStore.getState().save(slot, custom);
4755
+ return useStoryStore.getState().save(slot, custom);
4346
4756
  },
4347
4757
  load(slot) {
4348
- useStoryStore.getState().load(slot);
4758
+ return useStoryStore.getState().load(slot);
4349
4759
  },
4350
4760
  hasSave(slot) {
4351
4761
  return useStoryStore.getState().hasSave(slot);
@@ -4357,7 +4767,7 @@ function createStoryAPI() {
4357
4767
  return useStoryStore.getState().listSaves();
4358
4768
  },
4359
4769
  deleteSave(slot) {
4360
- useStoryStore.getState().deleteSave(slot);
4770
+ return useStoryStore.getState().deleteSave(slot);
4361
4771
  },
4362
4772
  exportSave(slot) {
4363
4773
  return useStoryStore.getState().exportSave(slot);
@@ -4616,6 +5026,10 @@ function declarationRegex(sigil) {
4616
5026
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
4617
5027
  }
4618
5028
  var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
5029
+ /** `{` followed by a sigil starts an interpolation block inside literal text. */
5030
+ var INTERP_START_RE = /^[$_@%]\w/;
5031
+ /** Quoted first argument of an input macro naming a story variable. */
5032
+ var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
4619
5033
  var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
4620
5034
  var VALID_VAR_TYPES = new Set([
4621
5035
  "number",
@@ -4707,21 +5121,155 @@ function validateRef(ref, schema, forLocals) {
4707
5121
  return null;
4708
5122
  }
4709
5123
  /**
5124
+ * Built-in input macros whose first argument names the bound story variable,
5125
+ * quoted or not (e.g. `{textbox "$name"}`).
5126
+ */
5127
+ var BUILTIN_STORE_VAR_MACROS = [
5128
+ "checkbox",
5129
+ "cycle",
5130
+ "listbox",
5131
+ "numberbox",
5132
+ "radiobutton",
5133
+ "textarea",
5134
+ "textbox"
5135
+ ];
5136
+ /** Report every `$var.path` in a code segment free of strings/comments. */
5137
+ function scanRefs(segment, onRef) {
5138
+ for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]);
5139
+ }
5140
+ /** Index of the quote closing the string opened at `start` (or the end). */
5141
+ function findClosingQuote(code, start) {
5142
+ const quote = code[start];
5143
+ let i = start + 1;
5144
+ while (i < code.length && code[i] !== quote) i += code[i] === "\\" ? 2 : 1;
5145
+ return Math.min(i, code.length);
5146
+ }
5147
+ /**
5148
+ * Scan literal text (string contents, HTML attribute values) for `{$…}`
5149
+ * interpolation blocks, which interpolating macros and HTML attributes
5150
+ * resolve at runtime. A bare `$word` in literal text is not a reference.
5151
+ */
5152
+ function scanInterpolations(text, onRef) {
5153
+ let i = text.indexOf("{");
5154
+ while (i !== -1) {
5155
+ const next = INTERP_START_RE.test(text.slice(i + 1, i + 3)) ? scanCode(text, i + 1, onRef, true) : i + 1;
5156
+ i = text.indexOf("{", next);
5157
+ }
5158
+ }
5159
+ /**
5160
+ * Scan a template literal starting just after its opening backtick: literal
5161
+ * parts are text, `${…}` parts are code. Returns the index just past the
5162
+ * closing backtick.
5163
+ */
5164
+ function scanTemplate(code, start, onRef) {
5165
+ let i = start;
5166
+ let textStart = start;
5167
+ while (i < code.length) {
5168
+ const ch = code[i];
5169
+ if (ch === "\\") i += 2;
5170
+ else if (ch === "`") {
5171
+ scanInterpolations(code.slice(textStart, i), onRef);
5172
+ return i + 1;
5173
+ } else if (ch === "$" && code[i + 1] === "{") {
5174
+ scanInterpolations(code.slice(textStart, i), onRef);
5175
+ i = textStart = scanCode(code, i + 2, onRef, true);
5176
+ } else i++;
5177
+ }
5178
+ scanInterpolations(code.slice(textStart), onRef);
5179
+ return code.length;
5180
+ }
5181
+ /**
5182
+ * Report `$var` references in JavaScript code. Like the string-aware
5183
+ * expression transformer, sigils inside string literals are left alone while
5184
+ * template-literal `${…}` parts are code; comments are skipped too. When
5185
+ * `nested`, stops at the `}` closing the enclosing block and returns the index
5186
+ * just past it.
5187
+ */
5188
+ function scanCode(code, start, onRef, nested = false) {
5189
+ let i = start;
5190
+ let segStart = start;
5191
+ let depth = 0;
5192
+ while (i < code.length) {
5193
+ const ch = code[i];
5194
+ const next = code[i + 1];
5195
+ const isComment = ch === "/" && (next === "/" || next === "*");
5196
+ if (ch === "\"" || ch === "'" || ch === "`" || isComment) {
5197
+ scanRefs(code.slice(segStart, i), onRef);
5198
+ if (ch === "`") i = scanTemplate(code, i + 1, onRef);
5199
+ else if (isComment) {
5200
+ const close = code.indexOf(next === "/" ? "\n" : "*/", i + 2);
5201
+ i = close === -1 ? code.length : next === "/" ? close : close + 2;
5202
+ } else {
5203
+ const close = findClosingQuote(code, i);
5204
+ scanInterpolations(code.slice(i + 1, close), onRef);
5205
+ i = Math.min(close + 1, code.length);
5206
+ }
5207
+ segStart = i;
5208
+ continue;
5209
+ }
5210
+ if (nested && ch === "{") depth++;
5211
+ else if (nested && ch === "}" && depth-- === 0) {
5212
+ scanRefs(code.slice(segStart, i), onRef);
5213
+ return i + 1;
5214
+ }
5215
+ i++;
5216
+ }
5217
+ scanRefs(code.slice(segStart), onRef);
5218
+ return code.length;
5219
+ }
5220
+ /**
5221
+ * Report the `$var` references a passage evaluates at runtime: `{$var}`
5222
+ * displays, `{$expr}` expressions, macro arguments and `{do}` bodies (as
5223
+ * code), quoted variable names bound by input macros, and `{$…}`
5224
+ * interpolations in HTML attributes. Prose is literal text and not scanned.
5225
+ */
5226
+ function collectPassageRefs(content, storeVarMacros, onRef) {
5227
+ const tokens = tokenize(content);
5228
+ for (let t = 0; t < tokens.length; t++) {
5229
+ const token = tokens[t];
5230
+ if (token.type === "variable") {
5231
+ if (token.scope === "variable" && token.name) onRef(token.name);
5232
+ } else if (token.type === "expression") scanCode(token.expression, 0, onRef);
5233
+ else if (token.type === "html") for (const value of Object.values(token.attributes)) scanInterpolations(value, onRef);
5234
+ else if (token.type === "macro" && !token.isClose) {
5235
+ scanCode(token.rawArgs, 0, onRef);
5236
+ if (storeVarMacros.has(token.name.toLowerCase())) {
5237
+ const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
5238
+ const quoted = QUOTED_VAR_ARG_RE.exec(first);
5239
+ if (quoted) onRef(quoted[1]);
5240
+ }
5241
+ if (token.name === "do") {
5242
+ let close = t + 1;
5243
+ while (close < tokens.length) {
5244
+ const c = tokens[close];
5245
+ if (c.type === "macro" && c.isClose && c.name === "do") break;
5246
+ close++;
5247
+ }
5248
+ if (close < tokens.length) {
5249
+ scanCode(content.slice(token.end, tokens[close].start), 0, onRef);
5250
+ t = close;
5251
+ }
5252
+ }
5253
+ }
5254
+ }
5255
+ }
5256
+ /**
4710
5257
  * Scan all passages for $var references, check against schema.
4711
5258
  * Returns list of error messages (empty = valid).
5259
+ *
5260
+ * `storeVarMacros` lists the input macros whose first argument names a bound
5261
+ * variable; it defaults to the built-in ones.
4712
5262
  */
4713
- function validatePassages(passages, schema) {
5263
+ function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_MACROS) {
4714
5264
  const errors = [];
5265
+ const storeVarSet = new Set(Array.from(storeVarMacros, (m) => m.toLowerCase()));
4715
5266
  for (const [name, passage] of passages) {
4716
5267
  if (name === "StoryVariables" || name === "StoryTransients") continue;
4717
5268
  const forLocals = extractForLocals(passage.content);
4718
- let match;
4719
- VAR_REF_RE.lastIndex = 0;
4720
- while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
4721
- const ref = match[1];
5269
+ collectPassageRefs(passage.content, storeVarSet, (ref) => {
4722
5270
  const error = validateRef(ref, schema, forLocals);
4723
5271
  if (error) errors.push(`Passage "${name}": ${error}`);
4724
- }
5272
+ });
4725
5273
  }
4726
5274
  return errors;
4727
5275
  }
@@ -5051,6 +5599,10 @@ function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue,
5051
5599
  valuesRef.current = localState;
5052
5600
  const getValues = useCallback(() => valuesRef.current, []);
5053
5601
  const update = useCallback((key, value) => {
5602
+ valuesRef.current = {
5603
+ ...valuesRef.current,
5604
+ [key]: value
5605
+ };
5054
5606
  setLocalMutations((prev) => ({
5055
5607
  ...prev,
5056
5608
  [key]: value
@@ -5132,6 +5684,30 @@ defineMacro({
5132
5684
  }
5133
5685
  });
5134
5686
  //#endregion
5687
+ //#region src/utils/live-locals.ts
5688
+ /**
5689
+ * Read-only view of a locals scope that always reflects its current values.
5690
+ *
5691
+ * Used as the LocalsValuesContext value for one-shot detached renders
5692
+ * ({button}/{link} bodies). Those trees are unmounted right after rendering,
5693
+ * so the owning scope's re-render never reaches them; reading through
5694
+ * getValues() lets a macro see a local assigned earlier in the same body.
5695
+ */
5696
+ function liveLocalsView(getValues) {
5697
+ return new Proxy({}, {
5698
+ get: (_, key) => typeof key === "string" ? getValues()[key] : void 0,
5699
+ has: (_, key) => key in getValues(),
5700
+ ownKeys: () => Reflect.ownKeys(getValues()),
5701
+ getOwnPropertyDescriptor: (_, key) => {
5702
+ const desc = Object.getOwnPropertyDescriptor(getValues(), key);
5703
+ return desc ? {
5704
+ ...desc,
5705
+ configurable: true
5706
+ } : void 0;
5707
+ }
5708
+ });
5709
+ }
5710
+ //#endregion
5135
5711
  //#region src/components/macros/Button.tsx
5136
5712
  defineMacro({
5137
5713
  name: "button",
@@ -5145,7 +5721,7 @@ defineMacro({
5145
5721
  render(h(NobrContext.Provider, { value: nobr }, h(LocalsUpdateContext.Provider, { value: {
5146
5722
  update: ctx.update,
5147
5723
  getValues: ctx.getValues
5148
- } }, h(LocalsValuesContext.Provider, { value: ctx.getValues() }, renderNodes(children)))), container);
5724
+ } }, h(LocalsValuesContext.Provider, { value: liveLocalsView(ctx.getValues) }, renderNodes(children)))), container);
5149
5725
  render(null, container);
5150
5726
  };
5151
5727
  ctx.useAction({
@@ -5188,7 +5764,7 @@ function renderChildrenDetached(children, getValues, update, nobr) {
5188
5764
  render(h(NobrContext.Provider, { value: nobr }, h(LocalsUpdateContext.Provider, { value: {
5189
5765
  update,
5190
5766
  getValues
5191
- } }, h(LocalsValuesContext.Provider, { value: getValues() }, renderNodes(children)))), container);
5767
+ } }, h(LocalsValuesContext.Provider, { value: liveLocalsView(getValues) }, renderNodes(children)))), container);
5192
5768
  render(null, container);
5193
5769
  }
5194
5770
  defineMacro({
@@ -5662,7 +6238,7 @@ function SaveManagerContent() {
5662
6238
  const handleNewSave = async () => {
5663
6239
  if (!ifid || !playthroughId) return;
5664
6240
  try {
5665
- await createSave(ifid, playthroughId, getSavePayload());
6241
+ await saveWithHooks(void 0, void 0, getSavePayload, (payload) => createSave(ifid, playthroughId, payload));
5666
6242
  showStatus("Save created");
5667
6243
  await refresh();
5668
6244
  } catch {
@@ -5671,7 +6247,9 @@ function SaveManagerContent() {
5671
6247
  };
5672
6248
  const handleOverwrite = async (saveId) => {
5673
6249
  try {
5674
- await overwriteSave(saveId, getSavePayload());
6250
+ await saveWithHooks(void 0, void 0, getSavePayload, async (payload) => {
6251
+ if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
6252
+ });
5675
6253
  showStatus("Save overwritten");
5676
6254
  await refresh();
5677
6255
  } catch {
@@ -5680,7 +6258,7 @@ function SaveManagerContent() {
5680
6258
  };
5681
6259
  const handleLoad = async (save) => {
5682
6260
  try {
5683
- loadFromPayload(save.payload);
6261
+ loadFromPayload(deserializePayload(save.payload));
5684
6262
  showStatus("Game loaded");
5685
6263
  if (closeDialog) setTimeout(closeDialog, 500);
5686
6264
  } catch {
@@ -5969,7 +6547,6 @@ defineMacro({
5969
6547
  merged: true,
5970
6548
  render({ rawArgs }, ctx) {
5971
6549
  const storyData = useStoryStore((s) => s.storyData);
5972
- if (!storyData) return null;
5973
6550
  const inline = /\binline\b/.test(rawArgs);
5974
6551
  const nameExpr = rawArgs.replace(/\binline\b/, "").trim();
5975
6552
  let passageName;
@@ -5979,14 +6556,15 @@ defineMacro({
5979
6556
  } catch {
5980
6557
  passageName = nameExpr.replace(/^["']|["']$/g, "");
5981
6558
  }
5982
- const passage = storyData.passages.get(passageName);
6559
+ const passage = storyData?.passages.get(passageName);
6560
+ const ast = ctx.hooks.useMemo(() => passage ? buildAST(tokenize(passage.content)) : null, [passage]);
6561
+ if (!storyData) return null;
5983
6562
  if (passage) useStoryStore.getState().trackRender(passageName);
5984
- if (!passage) return /* @__PURE__ */ jsx("span", {
6563
+ if (!passage || !ast) return /* @__PURE__ */ jsx("span", {
5985
6564
  class: "error",
5986
6565
  children: `{include${ctx.sourceLocation()}: passage "${passageName}" not found}`
5987
6566
  });
5988
6567
  const nobr = passage.tags.includes("nobr");
5989
- const ast = buildAST(tokenize(passage.content));
5990
6568
  const content = inline ? ctx.renderInlineNodes(ast) : ctx.renderNodes(ast, nobr ? { nobr: true } : void 0);
5991
6569
  if (nobr) return ctx.wrap(ctx.h(NobrContext.Provider, { value: true }, content));
5992
6570
  return ctx.wrap(content);
@@ -6269,8 +6847,25 @@ defineMacro({
6269
6847
  });
6270
6848
  //#endregion
6271
6849
  //#region src/components/Passage.tsx
6850
+ /**
6851
+ * Parsed AST per passage. The renderer keys children by AST node identity,
6852
+ * so re-rendering a passage (e.g. PassageReady on every PassageDisplay
6853
+ * render) must reuse its AST — a fresh parse would remount every macro in
6854
+ * it and re-run its {set}/{do} side effects (#175).
6855
+ */
6856
+ var astCache = /* @__PURE__ */ new WeakMap();
6857
+ function parsePassage(passage) {
6858
+ const cached = astCache.get(passage);
6859
+ if (cached && cached.content === passage.content) return cached.ast;
6860
+ const ast = buildAST(tokenize(passage.content));
6861
+ astCache.set(passage, {
6862
+ content: passage.content,
6863
+ ast
6864
+ });
6865
+ return ast;
6866
+ }
6272
6867
  function renderPassageContent(passage) {
6273
- return renderNodes(buildAST(tokenize(passage.content)), passage.tags.includes("nobr") ? { nobr: true } : void 0);
6868
+ return renderNodes(parsePassage(passage), passage.tags.includes("nobr") ? { nobr: true } : void 0);
6274
6869
  }
6275
6870
  var CODE_PASSAGES = new Set([
6276
6871
  "PassageReady",
@@ -6461,9 +7056,18 @@ defineMacro({
6461
7056
  interpolate: true,
6462
7057
  render(_props, ctx) {
6463
7058
  const currentPassage = useStoryStore((s) => s.currentPassage);
7059
+ const navigationId = useStoryStore((s) => s.navigationId);
6464
7060
  const storyData = useStoryStore((s) => s.storyData);
6465
7061
  const renderDeferred = useStoryStore((s) => s.renderDeferred);
6466
- const [displayedPassage, setDisplayedPassage] = useState(currentPassage);
7062
+ const [displayed, setDisplayed] = useState({
7063
+ passage: currentPassage,
7064
+ id: navigationId
7065
+ });
7066
+ const displayedPassage = displayed.passage;
7067
+ const setDisplayedPassage = (passage) => setDisplayed({
7068
+ passage,
7069
+ id: navigationId
7070
+ });
6467
7071
  const resolvedTypeRef = useRef("fade");
6468
7072
  const prevHistoryLenRef = useRef(0);
6469
7073
  const containerRef = useRef(null);
@@ -6483,7 +7087,7 @@ defineMacro({
6483
7087
  const isFirstLoad = !displayedPassage;
6484
7088
  const isRestart = history.length === 1 && historyIndex === 0 && prevLen > 1;
6485
7089
  const isLoad = history.length > 1 && historyIndex === history.length - 1 && prevLen > 1 && Math.abs(history.length - prevLen) > 1;
6486
- if (currentPassage === displayedPassage) return;
7090
+ if (navigationId === displayed.id) return;
6487
7091
  cancelTransition();
6488
7092
  const targetPassage = storyData?.passages.get(currentPassage);
6489
7093
  if (!targetPassage) {
@@ -6553,7 +7157,7 @@ defineMacro({
6553
7157
  return;
6554
7158
  }
6555
7159
  setDisplayedPassage(currentPassage);
6556
- }, [currentPassage]);
7160
+ }, [navigationId]);
6557
7161
  const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
6558
7162
  if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
6559
7163
  class: "error",
@@ -6570,13 +7174,13 @@ defineMacro({
6570
7174
  children: [!renderDeferred && readyPassage && /* @__PURE__ */ jsx("div", {
6571
7175
  hidden: true,
6572
7176
  children: renderPassageContent(readyPassage)
6573
- }, `ready-${currentPassage}`), /* @__PURE__ */ jsx("div", {
7177
+ }, `ready-${navigationId}`), /* @__PURE__ */ jsx("div", {
6574
7178
  class: "passage-container",
6575
7179
  ref: containerRef,
6576
7180
  children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
6577
7181
  passage: effectivePassage,
6578
7182
  dataTransition: renderDeferred ? "none" : resolvedTypeRef.current
6579
- }, renderDeferred ? "loading" : displayedPassage)
7183
+ }, renderDeferred ? "loading" : `nav-${displayed.id}`)
6580
7184
  })]
6581
7185
  });
6582
7186
  }
@@ -6623,7 +7227,7 @@ defineMacro({
6623
7227
  const registered = hooks.useRef(false);
6624
7228
  if (!registered.current) {
6625
7229
  registered.current = true;
6626
- addTrigger(condition, options);
7230
+ addMacroTrigger(condition, options);
6627
7231
  }
6628
7232
  return null;
6629
7233
  }
@@ -6697,7 +7301,8 @@ function boot() {
6697
7301
  throw new Error(`spindle: ${msg}`);
6698
7302
  }
6699
7303
  const schema = parseStoryVariables(storyVarsPassage.content);
6700
- const errors = validatePassages(storyData.passages, schema);
7304
+ const storeVarMacros = getMacroRegistry().filter((m) => m.storeVar).map((m) => m.name);
7305
+ const errors = validatePassages(storyData.passages, schema, storeVarMacros);
6701
7306
  let transientDefaults = {};
6702
7307
  const storyTransientsPassage = storyData.passages.get("StoryTransients");
6703
7308
  if (storyTransientsPassage) {
@@ -6740,21 +7345,14 @@ function boot() {
6740
7345
  if (isBlock) registerBlockMacro(widgetName);
6741
7346
  }
6742
7347
  }
6743
- let prevPassage = "";
7348
+ let prevNavigationId = useStoryStore.getState().navigationId;
6744
7349
  useStoryStore.subscribe((state) => {
6745
- if (state.currentPassage !== prevPassage) {
6746
- prevPassage = state.currentPassage;
7350
+ if (state.navigationId !== prevNavigationId) {
7351
+ prevNavigationId = state.navigationId;
6747
7352
  resetIdCounters();
6748
7353
  }
6749
7354
  });
6750
- let prevVars = useStoryStore.getState().variables;
6751
- let prevHistoryIndex = useStoryStore.getState().historyIndex;
6752
- useStoryStore.subscribe((state) => {
6753
- if (state.variables !== prevVars) if (state.historyIndex === prevHistoryIndex) checkTriggers();
6754
- else reinitTriggerState();
6755
- prevVars = state.variables;
6756
- prevHistoryIndex = state.historyIndex;
6757
- });
7355
+ connectTriggersToStore();
6758
7356
  const storyInterfacePassage = storyData.passages.get("StoryInterface");
6759
7357
  if (storyInterfacePassage && !storyInterfacePassage.content.includes("{passage}")) console.warn("spindle: StoryInterface passage does not contain {passage}. The current passage will not be displayed.");
6760
7358
  const root = document.getElementById("root");