@rohal12/spindle 0.50.0 → 0.50.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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +828 -243
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +1 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +21 -3
- package/src/components/macros/Button.tsx +2 -1
- package/src/components/macros/Do.tsx +1 -0
- package/src/components/macros/For.tsx +4 -0
- package/src/components/macros/Include.tsx +13 -6
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/PassageDisplay.tsx +17 -10
- package/src/components/macros/SaveManager.tsx +18 -5
- package/src/components/macros/Watch.tsx +3 -2
- package/src/components/macros/WidgetInvocation.tsx +4 -0
- package/src/execute-mutation.ts +83 -31
- package/src/index.tsx +13 -19
- package/src/interpolation.ts +4 -9
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/saves/save-manager.ts +97 -39
- package/src/settings.ts +15 -0
- package/src/store.ts +209 -56
- package/src/story-api.ts +21 -19
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/utils/live-locals.ts +22 -0
package/dist/pkg/headless.js
CHANGED
|
@@ -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] === "{")
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
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
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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,
|
|
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
|
-
|
|
616
|
-
|
|
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,
|
|
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
|
-
|
|
1306
|
-
|
|
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
|
|
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
|
|
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 (`&`, `{`) 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2553
|
-
if (slot
|
|
2554
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
"
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
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
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
*
|
|
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
|
|
2869
|
-
const
|
|
2870
|
-
if (!
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
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
|
|
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
|
-
|
|
2925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
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,6 +3470,20 @@ 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);
|
|
@@ -3129,6 +3492,7 @@ function restorePRNGFromMoment(moment) {
|
|
|
3129
3492
|
var useStoryStore = create()(immer((set, get) => ({
|
|
3130
3493
|
storyData: null,
|
|
3131
3494
|
currentPassage: "",
|
|
3495
|
+
navigationId: 0,
|
|
3132
3496
|
variables: {},
|
|
3133
3497
|
variableDefaults: {},
|
|
3134
3498
|
transient: {},
|
|
@@ -3172,6 +3536,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3172
3536
|
set((state) => {
|
|
3173
3537
|
state.storyData = storyData;
|
|
3174
3538
|
state.currentPassage = startPassage.name;
|
|
3539
|
+
state.navigationId++;
|
|
3175
3540
|
state.variables = initialVars;
|
|
3176
3541
|
state.variableDefaults = variableDefaults;
|
|
3177
3542
|
state.transient = deepClone(transientDefaults);
|
|
@@ -3205,7 +3570,11 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3205
3570
|
}).catch((err) => console.error("spindle: failed to init save system", err));
|
|
3206
3571
|
},
|
|
3207
3572
|
navigate: (passageName) => {
|
|
3208
|
-
|
|
3573
|
+
if (navigationTriggerPhase) {
|
|
3574
|
+
deferredNavigations.push(passageName);
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
const { storyData } = get();
|
|
3209
3578
|
if (!storyData) return;
|
|
3210
3579
|
if (SPECIAL_PASSAGES.has(passageName)) {
|
|
3211
3580
|
console.error(`spindle: Cannot navigate to special passage "${passageName}".`);
|
|
@@ -3217,12 +3586,14 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3217
3586
|
}
|
|
3218
3587
|
const previousPassage = get().currentPassage;
|
|
3219
3588
|
emit("beforenavigate", passageName);
|
|
3220
|
-
const patchEntry = computeVarPatches(lastNavigationVars,
|
|
3589
|
+
const patchEntry = computeVarPatches(lastNavigationVars, get().variables);
|
|
3221
3590
|
set((state) => {
|
|
3222
3591
|
state.temporary = {};
|
|
3223
3592
|
state.currentPassage = passageName;
|
|
3593
|
+
state.navigationId++;
|
|
3224
3594
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
3225
3595
|
patchEntries.length = state.historyIndex;
|
|
3596
|
+
if (serializedHistory.length > state.historyIndex + 1) serializedHistory.length = state.historyIndex + 1;
|
|
3226
3597
|
patchEntries.push(patchEntry);
|
|
3227
3598
|
state.history.push({
|
|
3228
3599
|
passage: passageName,
|
|
@@ -3240,41 +3611,61 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3240
3611
|
state.visitCounts[passageName] = (state.visitCounts[passageName] ?? 0) + 1;
|
|
3241
3612
|
state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
|
|
3242
3613
|
});
|
|
3614
|
+
const enteredVars = get().variables;
|
|
3615
|
+
navigationTriggerPhase = true;
|
|
3616
|
+
try {
|
|
3617
|
+
checkTriggersOnNavigation();
|
|
3618
|
+
} finally {
|
|
3619
|
+
navigationTriggerPhase = false;
|
|
3620
|
+
}
|
|
3621
|
+
const deferred = deferredNavigations;
|
|
3622
|
+
deferredNavigations = [];
|
|
3623
|
+
if (get().variables !== enteredVars) rerecordNewestMoment(get().variables);
|
|
3624
|
+
const prng = snapshotPRNG();
|
|
3625
|
+
const recorded = get().history[get().historyIndex].prng;
|
|
3626
|
+
if (prng?.seed !== recorded?.seed || prng?.pull !== recorded?.pull) set((state) => {
|
|
3627
|
+
state.history[state.historyIndex].prng = prng;
|
|
3628
|
+
});
|
|
3243
3629
|
lastNavigationVars = get().variables;
|
|
3244
3630
|
persistSession(get);
|
|
3245
3631
|
emit("afternavigate", passageName, previousPassage);
|
|
3632
|
+
for (const next of deferred) get().navigate(next);
|
|
3246
3633
|
},
|
|
3247
3634
|
goBack: () => {
|
|
3248
|
-
const { historyIndex
|
|
3635
|
+
const { historyIndex } = get();
|
|
3249
3636
|
if (historyIndex <= 0) return;
|
|
3250
3637
|
const previousPassage = get().currentPassage;
|
|
3251
3638
|
const targetPassage = get().history[historyIndex - 1].passage;
|
|
3252
3639
|
emit("beforenavigate", targetPassage);
|
|
3253
|
-
const restoredVars = deepClone(
|
|
3640
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
|
|
3254
3641
|
set((state) => {
|
|
3255
3642
|
state.historyIndex--;
|
|
3256
3643
|
state.currentPassage = state.history[state.historyIndex].passage;
|
|
3644
|
+
state.navigationId++;
|
|
3257
3645
|
state.variables = restoredVars;
|
|
3258
3646
|
state.temporary = {};
|
|
3259
3647
|
});
|
|
3648
|
+
reinitTriggerState();
|
|
3260
3649
|
lastNavigationVars = get().variables;
|
|
3261
3650
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
3262
3651
|
persistSession(get);
|
|
3263
3652
|
emit("afternavigate", targetPassage, previousPassage);
|
|
3264
3653
|
},
|
|
3265
3654
|
goForward: () => {
|
|
3266
|
-
const { historyIndex, history: hist
|
|
3655
|
+
const { historyIndex, history: hist } = get();
|
|
3267
3656
|
if (historyIndex >= hist.length - 1) return;
|
|
3268
3657
|
const previousPassage = get().currentPassage;
|
|
3269
3658
|
const targetPassage = hist[historyIndex + 1].passage;
|
|
3270
3659
|
emit("beforenavigate", targetPassage);
|
|
3271
|
-
const restoredVars = deepClone(
|
|
3660
|
+
const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
|
|
3272
3661
|
set((state) => {
|
|
3273
3662
|
state.historyIndex++;
|
|
3274
3663
|
state.currentPassage = state.history[state.historyIndex].passage;
|
|
3664
|
+
state.navigationId++;
|
|
3275
3665
|
state.variables = restoredVars;
|
|
3276
3666
|
state.temporary = {};
|
|
3277
3667
|
});
|
|
3668
|
+
reinitTriggerState();
|
|
3278
3669
|
lastNavigationVars = get().variables;
|
|
3279
3670
|
restorePRNGFromMoment(get().history[get().historyIndex]);
|
|
3280
3671
|
persistSession(get);
|
|
@@ -3310,6 +3701,11 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3310
3701
|
delete state.transient[name];
|
|
3311
3702
|
});
|
|
3312
3703
|
},
|
|
3704
|
+
updateVariables: (recipe) => {
|
|
3705
|
+
set((state) => {
|
|
3706
|
+
recipe(state);
|
|
3707
|
+
});
|
|
3708
|
+
},
|
|
3313
3709
|
trackRender: (passageName) => {
|
|
3314
3710
|
set((state) => {
|
|
3315
3711
|
state.renderCounts[passageName] = (state.renderCounts[passageName] ?? 0) + 1;
|
|
@@ -3332,6 +3728,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3332
3728
|
resetModuleState(deepClone(initialVars));
|
|
3333
3729
|
set((state) => {
|
|
3334
3730
|
state.currentPassage = startPassage.name;
|
|
3731
|
+
state.navigationId++;
|
|
3335
3732
|
state.variables = initialVars;
|
|
3336
3733
|
state.transient = deepClone(transientDefaults);
|
|
3337
3734
|
state.temporary = {};
|
|
@@ -3358,19 +3755,17 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3358
3755
|
save: (slot, custom) => {
|
|
3359
3756
|
const { storyData, playthroughId } = get();
|
|
3360
3757
|
if (!storyData) return;
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
quickSave(storyData.ifid, playthroughId, payload, slot, custom).then(() => {
|
|
3758
|
+
saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
|
|
3759
|
+
set((state) => {
|
|
3760
|
+
state.saveError = null;
|
|
3761
|
+
});
|
|
3762
|
+
await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
|
|
3367
3763
|
set((state) => {
|
|
3368
3764
|
state.knownSaves = {
|
|
3369
3765
|
...state.knownSaves,
|
|
3370
3766
|
[slot ?? ""]: true
|
|
3371
3767
|
};
|
|
3372
3768
|
});
|
|
3373
|
-
emit("aftersave", slot);
|
|
3374
3769
|
}).catch((err) => {
|
|
3375
3770
|
console.error("spindle: failed to save", err);
|
|
3376
3771
|
set((state) => {
|
|
@@ -3386,7 +3781,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3386
3781
|
});
|
|
3387
3782
|
loadQuickSave(storyData.ifid, slot).then((payload) => {
|
|
3388
3783
|
if (!payload) return;
|
|
3389
|
-
get().loadFromPayload(payload);
|
|
3784
|
+
get().loadFromPayload(payload, slot);
|
|
3390
3785
|
}).catch((err) => {
|
|
3391
3786
|
console.error("spindle: failed to load save", err);
|
|
3392
3787
|
set((state) => {
|
|
@@ -3499,26 +3894,33 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3499
3894
|
prng: snapshotPRNG()
|
|
3500
3895
|
};
|
|
3501
3896
|
},
|
|
3502
|
-
loadFromPayload: (payload) => {
|
|
3897
|
+
loadFromPayload: (payload, slot) => {
|
|
3503
3898
|
if (payload.history.length === 0) {
|
|
3504
3899
|
console.warn("loadFromPayload: rejecting payload with empty history");
|
|
3505
3900
|
return;
|
|
3506
3901
|
}
|
|
3507
|
-
emit("beforeload",
|
|
3508
|
-
const
|
|
3902
|
+
emit("beforeload", slot);
|
|
3903
|
+
const entry = loadedEntryMoment(payload);
|
|
3904
|
+
const base = deepClone(payload.history[0]?.variables ?? {});
|
|
3509
3905
|
const newPatchEntries = [];
|
|
3510
3906
|
let prevVars = base;
|
|
3511
3907
|
for (let i = 1; i < payload.history.length; i++) {
|
|
3512
|
-
const currVars =
|
|
3908
|
+
const currVars = deepClone(payload.history[i].variables);
|
|
3513
3909
|
newPatchEntries.push(computeVarPatches(prevVars, currVars));
|
|
3514
3910
|
prevVars = currVars;
|
|
3515
3911
|
}
|
|
3516
3912
|
variableBase = deepClone(base);
|
|
3517
3913
|
patchEntries = newPatchEntries;
|
|
3518
|
-
serializedHistory =
|
|
3914
|
+
serializedHistory = payload.history.map((m) => ({
|
|
3915
|
+
passage: m.passage,
|
|
3916
|
+
variables: serialize(m.variables),
|
|
3917
|
+
timestamp: m.timestamp,
|
|
3918
|
+
prng: m.prng
|
|
3919
|
+
}));
|
|
3519
3920
|
set((state) => {
|
|
3520
3921
|
state.currentPassage = payload.passage;
|
|
3521
|
-
state.
|
|
3922
|
+
state.navigationId++;
|
|
3923
|
+
state.variables = deepClone(entry?.variables ?? payload.variables);
|
|
3522
3924
|
state.history = payload.history.map((m) => ({
|
|
3523
3925
|
passage: m.passage,
|
|
3524
3926
|
timestamp: m.timestamp,
|
|
@@ -3530,10 +3932,13 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3530
3932
|
state.temporary = {};
|
|
3531
3933
|
state.transient = deepClone(get().transientDefaults);
|
|
3532
3934
|
});
|
|
3533
|
-
|
|
3534
|
-
|
|
3935
|
+
reinitTriggerState();
|
|
3936
|
+
lastNavigationVars = reconstructVarsAt(get().historyIndex);
|
|
3937
|
+
const prng = entry?.prng !== void 0 ? entry.prng : payload.prng;
|
|
3938
|
+
if (prng) restorePRNG(prng.seed, prng.pull);
|
|
3535
3939
|
else resetPRNG();
|
|
3536
|
-
|
|
3940
|
+
persistSession(get);
|
|
3941
|
+
emit("afterload", slot);
|
|
3537
3942
|
},
|
|
3538
3943
|
getHistoryVariables: (index) => {
|
|
3539
3944
|
return deepClone(reconstructVarsAt(index));
|
|
@@ -3873,6 +4278,7 @@ function parseStoryData() {
|
|
|
3873
4278
|
var definitions = /* @__PURE__ */ new Map();
|
|
3874
4279
|
var values = {};
|
|
3875
4280
|
var storageLoaded = false;
|
|
4281
|
+
var unsubscribeStoryData = null;
|
|
3876
4282
|
function storageKey() {
|
|
3877
4283
|
return `spindle.${useStoryStore.getState().storyData?.ifid || "unknown"}.settings`;
|
|
3878
4284
|
}
|
|
@@ -3881,6 +4287,15 @@ function persist() {
|
|
|
3881
4287
|
}
|
|
3882
4288
|
function loadFromStorage() {
|
|
3883
4289
|
if (storageLoaded) return;
|
|
4290
|
+
if (!useStoryStore.getState().storyData?.ifid) {
|
|
4291
|
+
if (!unsubscribeStoryData) unsubscribeStoryData = useStoryStore.subscribe((state) => {
|
|
4292
|
+
if (!state.storyData?.ifid) return;
|
|
4293
|
+
unsubscribeStoryData?.();
|
|
4294
|
+
unsubscribeStoryData = null;
|
|
4295
|
+
loadFromStorage();
|
|
4296
|
+
});
|
|
4297
|
+
return;
|
|
4298
|
+
}
|
|
3884
4299
|
storageLoaded = true;
|
|
3885
4300
|
try {
|
|
3886
4301
|
const raw = localStorage.getItem(storageKey());
|
|
@@ -3948,24 +4363,6 @@ var settings = {
|
|
|
3948
4363
|
}
|
|
3949
4364
|
};
|
|
3950
4365
|
//#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
4366
|
//#region src/action-registry.ts
|
|
3970
4367
|
var actions = /* @__PURE__ */ new Map();
|
|
3971
4368
|
var idCounters = /* @__PURE__ */ new Map();
|
|
@@ -4304,19 +4701,12 @@ function warnIfUndeclared(isTransient, key) {
|
|
|
4304
4701
|
warnedUndeclared.add(label);
|
|
4305
4702
|
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
4703
|
}
|
|
4307
|
-
/** Set a single variable, resolving dot-paths if present. */
|
|
4308
|
-
function setOne(name, value) {
|
|
4704
|
+
/** Set a single variable on an Immer draft, resolving dot-paths if present. */
|
|
4705
|
+
function setOne(draft, name, value) {
|
|
4309
4706
|
const { isTransient, key } = parseName(name);
|
|
4310
|
-
const namespace = isTransient ?
|
|
4311
|
-
|
|
4312
|
-
|
|
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
|
-
}
|
|
4707
|
+
const namespace = isTransient ? draft.transient : draft.variables;
|
|
4708
|
+
if (key.includes(".")) setByPath(namespace, key, value);
|
|
4709
|
+
else namespace[key] = value;
|
|
4320
4710
|
}
|
|
4321
4711
|
function createStoryAPI() {
|
|
4322
4712
|
return {
|
|
@@ -4326,8 +4716,15 @@ function createStoryAPI() {
|
|
|
4326
4716
|
return key.includes(".") ? getByPath(store, key) : store[key];
|
|
4327
4717
|
},
|
|
4328
4718
|
set(nameOrVars, value) {
|
|
4329
|
-
|
|
4330
|
-
|
|
4719
|
+
const names = typeof nameOrVars === "string" ? [nameOrVars] : Object.keys(nameOrVars);
|
|
4720
|
+
for (const name of names) {
|
|
4721
|
+
const { isTransient, key } = parseName(name);
|
|
4722
|
+
warnIfUndeclared(isTransient, key);
|
|
4723
|
+
}
|
|
4724
|
+
useStoryStore.getState().updateVariables((draft) => {
|
|
4725
|
+
if (typeof nameOrVars === "string") setOne(draft, nameOrVars, value);
|
|
4726
|
+
else for (const [k, v] of Object.entries(nameOrVars)) setOne(draft, k, v);
|
|
4727
|
+
});
|
|
4331
4728
|
},
|
|
4332
4729
|
goto(passageName) {
|
|
4333
4730
|
useStoryStore.getState().navigate(passageName);
|
|
@@ -4616,6 +5013,10 @@ function declarationRegex(sigil) {
|
|
|
4616
5013
|
return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
|
|
4617
5014
|
}
|
|
4618
5015
|
var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
|
|
5016
|
+
/** `{` followed by a sigil starts an interpolation block inside literal text. */
|
|
5017
|
+
var INTERP_START_RE = /^[$_@%]\w/;
|
|
5018
|
+
/** Quoted first argument of an input macro naming a story variable. */
|
|
5019
|
+
var QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
|
|
4619
5020
|
var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
4620
5021
|
var VALID_VAR_TYPES = new Set([
|
|
4621
5022
|
"number",
|
|
@@ -4707,21 +5108,155 @@ function validateRef(ref, schema, forLocals) {
|
|
|
4707
5108
|
return null;
|
|
4708
5109
|
}
|
|
4709
5110
|
/**
|
|
5111
|
+
* Built-in input macros whose first argument names the bound story variable,
|
|
5112
|
+
* quoted or not (e.g. `{textbox "$name"}`).
|
|
5113
|
+
*/
|
|
5114
|
+
var BUILTIN_STORE_VAR_MACROS = [
|
|
5115
|
+
"checkbox",
|
|
5116
|
+
"cycle",
|
|
5117
|
+
"listbox",
|
|
5118
|
+
"numberbox",
|
|
5119
|
+
"radiobutton",
|
|
5120
|
+
"textarea",
|
|
5121
|
+
"textbox"
|
|
5122
|
+
];
|
|
5123
|
+
/** Report every `$var.path` in a code segment free of strings/comments. */
|
|
5124
|
+
function scanRefs(segment, onRef) {
|
|
5125
|
+
for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]);
|
|
5126
|
+
}
|
|
5127
|
+
/** Index of the quote closing the string opened at `start` (or the end). */
|
|
5128
|
+
function findClosingQuote(code, start) {
|
|
5129
|
+
const quote = code[start];
|
|
5130
|
+
let i = start + 1;
|
|
5131
|
+
while (i < code.length && code[i] !== quote) i += code[i] === "\\" ? 2 : 1;
|
|
5132
|
+
return Math.min(i, code.length);
|
|
5133
|
+
}
|
|
5134
|
+
/**
|
|
5135
|
+
* Scan literal text (string contents, HTML attribute values) for `{$…}`
|
|
5136
|
+
* interpolation blocks, which interpolating macros and HTML attributes
|
|
5137
|
+
* resolve at runtime. A bare `$word` in literal text is not a reference.
|
|
5138
|
+
*/
|
|
5139
|
+
function scanInterpolations(text, onRef) {
|
|
5140
|
+
let i = text.indexOf("{");
|
|
5141
|
+
while (i !== -1) {
|
|
5142
|
+
const next = INTERP_START_RE.test(text.slice(i + 1, i + 3)) ? scanCode(text, i + 1, onRef, true) : i + 1;
|
|
5143
|
+
i = text.indexOf("{", next);
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
/**
|
|
5147
|
+
* Scan a template literal starting just after its opening backtick: literal
|
|
5148
|
+
* parts are text, `${…}` parts are code. Returns the index just past the
|
|
5149
|
+
* closing backtick.
|
|
5150
|
+
*/
|
|
5151
|
+
function scanTemplate(code, start, onRef) {
|
|
5152
|
+
let i = start;
|
|
5153
|
+
let textStart = start;
|
|
5154
|
+
while (i < code.length) {
|
|
5155
|
+
const ch = code[i];
|
|
5156
|
+
if (ch === "\\") i += 2;
|
|
5157
|
+
else if (ch === "`") {
|
|
5158
|
+
scanInterpolations(code.slice(textStart, i), onRef);
|
|
5159
|
+
return i + 1;
|
|
5160
|
+
} else if (ch === "$" && code[i + 1] === "{") {
|
|
5161
|
+
scanInterpolations(code.slice(textStart, i), onRef);
|
|
5162
|
+
i = textStart = scanCode(code, i + 2, onRef, true);
|
|
5163
|
+
} else i++;
|
|
5164
|
+
}
|
|
5165
|
+
scanInterpolations(code.slice(textStart), onRef);
|
|
5166
|
+
return code.length;
|
|
5167
|
+
}
|
|
5168
|
+
/**
|
|
5169
|
+
* Report `$var` references in JavaScript code. Like the string-aware
|
|
5170
|
+
* expression transformer, sigils inside string literals are left alone while
|
|
5171
|
+
* template-literal `${…}` parts are code; comments are skipped too. When
|
|
5172
|
+
* `nested`, stops at the `}` closing the enclosing block and returns the index
|
|
5173
|
+
* just past it.
|
|
5174
|
+
*/
|
|
5175
|
+
function scanCode(code, start, onRef, nested = false) {
|
|
5176
|
+
let i = start;
|
|
5177
|
+
let segStart = start;
|
|
5178
|
+
let depth = 0;
|
|
5179
|
+
while (i < code.length) {
|
|
5180
|
+
const ch = code[i];
|
|
5181
|
+
const next = code[i + 1];
|
|
5182
|
+
const isComment = ch === "/" && (next === "/" || next === "*");
|
|
5183
|
+
if (ch === "\"" || ch === "'" || ch === "`" || isComment) {
|
|
5184
|
+
scanRefs(code.slice(segStart, i), onRef);
|
|
5185
|
+
if (ch === "`") i = scanTemplate(code, i + 1, onRef);
|
|
5186
|
+
else if (isComment) {
|
|
5187
|
+
const close = code.indexOf(next === "/" ? "\n" : "*/", i + 2);
|
|
5188
|
+
i = close === -1 ? code.length : next === "/" ? close : close + 2;
|
|
5189
|
+
} else {
|
|
5190
|
+
const close = findClosingQuote(code, i);
|
|
5191
|
+
scanInterpolations(code.slice(i + 1, close), onRef);
|
|
5192
|
+
i = Math.min(close + 1, code.length);
|
|
5193
|
+
}
|
|
5194
|
+
segStart = i;
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
5197
|
+
if (nested && ch === "{") depth++;
|
|
5198
|
+
else if (nested && ch === "}" && depth-- === 0) {
|
|
5199
|
+
scanRefs(code.slice(segStart, i), onRef);
|
|
5200
|
+
return i + 1;
|
|
5201
|
+
}
|
|
5202
|
+
i++;
|
|
5203
|
+
}
|
|
5204
|
+
scanRefs(code.slice(segStart), onRef);
|
|
5205
|
+
return code.length;
|
|
5206
|
+
}
|
|
5207
|
+
/**
|
|
5208
|
+
* Report the `$var` references a passage evaluates at runtime: `{$var}`
|
|
5209
|
+
* displays, `{$expr}` expressions, macro arguments and `{do}` bodies (as
|
|
5210
|
+
* code), quoted variable names bound by input macros, and `{$…}`
|
|
5211
|
+
* interpolations in HTML attributes. Prose is literal text and not scanned.
|
|
5212
|
+
*/
|
|
5213
|
+
function collectPassageRefs(content, storeVarMacros, onRef) {
|
|
5214
|
+
const tokens = tokenize(content);
|
|
5215
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
5216
|
+
const token = tokens[t];
|
|
5217
|
+
if (token.type === "variable") {
|
|
5218
|
+
if (token.scope === "variable" && token.name) onRef(token.name);
|
|
5219
|
+
} else if (token.type === "expression") scanCode(token.expression, 0, onRef);
|
|
5220
|
+
else if (token.type === "html") for (const value of Object.values(token.attributes)) scanInterpolations(value, onRef);
|
|
5221
|
+
else if (token.type === "macro" && !token.isClose) {
|
|
5222
|
+
scanCode(token.rawArgs, 0, onRef);
|
|
5223
|
+
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
5224
|
+
const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
|
|
5225
|
+
const quoted = QUOTED_VAR_ARG_RE.exec(first);
|
|
5226
|
+
if (quoted) onRef(quoted[1]);
|
|
5227
|
+
}
|
|
5228
|
+
if (token.name === "do") {
|
|
5229
|
+
let close = t + 1;
|
|
5230
|
+
while (close < tokens.length) {
|
|
5231
|
+
const c = tokens[close];
|
|
5232
|
+
if (c.type === "macro" && c.isClose && c.name === "do") break;
|
|
5233
|
+
close++;
|
|
5234
|
+
}
|
|
5235
|
+
if (close < tokens.length) {
|
|
5236
|
+
scanCode(content.slice(token.end, tokens[close].start), 0, onRef);
|
|
5237
|
+
t = close;
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
5243
|
+
/**
|
|
4710
5244
|
* Scan all passages for $var references, check against schema.
|
|
4711
5245
|
* Returns list of error messages (empty = valid).
|
|
5246
|
+
*
|
|
5247
|
+
* `storeVarMacros` lists the input macros whose first argument names a bound
|
|
5248
|
+
* variable; it defaults to the built-in ones.
|
|
4712
5249
|
*/
|
|
4713
|
-
function validatePassages(passages, schema) {
|
|
5250
|
+
function validatePassages(passages, schema, storeVarMacros = BUILTIN_STORE_VAR_MACROS) {
|
|
4714
5251
|
const errors = [];
|
|
5252
|
+
const storeVarSet = new Set(Array.from(storeVarMacros, (m) => m.toLowerCase()));
|
|
4715
5253
|
for (const [name, passage] of passages) {
|
|
4716
5254
|
if (name === "StoryVariables" || name === "StoryTransients") continue;
|
|
4717
5255
|
const forLocals = extractForLocals(passage.content);
|
|
4718
|
-
|
|
4719
|
-
VAR_REF_RE.lastIndex = 0;
|
|
4720
|
-
while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
|
|
4721
|
-
const ref = match[1];
|
|
5256
|
+
collectPassageRefs(passage.content, storeVarSet, (ref) => {
|
|
4722
5257
|
const error = validateRef(ref, schema, forLocals);
|
|
4723
5258
|
if (error) errors.push(`Passage "${name}": ${error}`);
|
|
4724
|
-
}
|
|
5259
|
+
});
|
|
4725
5260
|
}
|
|
4726
5261
|
return errors;
|
|
4727
5262
|
}
|
|
@@ -5051,6 +5586,10 @@ function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue,
|
|
|
5051
5586
|
valuesRef.current = localState;
|
|
5052
5587
|
const getValues = useCallback(() => valuesRef.current, []);
|
|
5053
5588
|
const update = useCallback((key, value) => {
|
|
5589
|
+
valuesRef.current = {
|
|
5590
|
+
...valuesRef.current,
|
|
5591
|
+
[key]: value
|
|
5592
|
+
};
|
|
5054
5593
|
setLocalMutations((prev) => ({
|
|
5055
5594
|
...prev,
|
|
5056
5595
|
[key]: value
|
|
@@ -5132,6 +5671,30 @@ defineMacro({
|
|
|
5132
5671
|
}
|
|
5133
5672
|
});
|
|
5134
5673
|
//#endregion
|
|
5674
|
+
//#region src/utils/live-locals.ts
|
|
5675
|
+
/**
|
|
5676
|
+
* Read-only view of a locals scope that always reflects its current values.
|
|
5677
|
+
*
|
|
5678
|
+
* Used as the LocalsValuesContext value for one-shot detached renders
|
|
5679
|
+
* ({button}/{link} bodies). Those trees are unmounted right after rendering,
|
|
5680
|
+
* so the owning scope's re-render never reaches them; reading through
|
|
5681
|
+
* getValues() lets a macro see a local assigned earlier in the same body.
|
|
5682
|
+
*/
|
|
5683
|
+
function liveLocalsView(getValues) {
|
|
5684
|
+
return new Proxy({}, {
|
|
5685
|
+
get: (_, key) => typeof key === "string" ? getValues()[key] : void 0,
|
|
5686
|
+
has: (_, key) => key in getValues(),
|
|
5687
|
+
ownKeys: () => Reflect.ownKeys(getValues()),
|
|
5688
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
5689
|
+
const desc = Object.getOwnPropertyDescriptor(getValues(), key);
|
|
5690
|
+
return desc ? {
|
|
5691
|
+
...desc,
|
|
5692
|
+
configurable: true
|
|
5693
|
+
} : void 0;
|
|
5694
|
+
}
|
|
5695
|
+
});
|
|
5696
|
+
}
|
|
5697
|
+
//#endregion
|
|
5135
5698
|
//#region src/components/macros/Button.tsx
|
|
5136
5699
|
defineMacro({
|
|
5137
5700
|
name: "button",
|
|
@@ -5145,7 +5708,7 @@ defineMacro({
|
|
|
5145
5708
|
render(h(NobrContext.Provider, { value: nobr }, h(LocalsUpdateContext.Provider, { value: {
|
|
5146
5709
|
update: ctx.update,
|
|
5147
5710
|
getValues: ctx.getValues
|
|
5148
|
-
} }, h(LocalsValuesContext.Provider, { value: ctx.getValues
|
|
5711
|
+
} }, h(LocalsValuesContext.Provider, { value: liveLocalsView(ctx.getValues) }, renderNodes(children)))), container);
|
|
5149
5712
|
render(null, container);
|
|
5150
5713
|
};
|
|
5151
5714
|
ctx.useAction({
|
|
@@ -5188,7 +5751,7 @@ function renderChildrenDetached(children, getValues, update, nobr) {
|
|
|
5188
5751
|
render(h(NobrContext.Provider, { value: nobr }, h(LocalsUpdateContext.Provider, { value: {
|
|
5189
5752
|
update,
|
|
5190
5753
|
getValues
|
|
5191
|
-
} }, h(LocalsValuesContext.Provider, { value: getValues
|
|
5754
|
+
} }, h(LocalsValuesContext.Provider, { value: liveLocalsView(getValues) }, renderNodes(children)))), container);
|
|
5192
5755
|
render(null, container);
|
|
5193
5756
|
}
|
|
5194
5757
|
defineMacro({
|
|
@@ -5662,7 +6225,7 @@ function SaveManagerContent() {
|
|
|
5662
6225
|
const handleNewSave = async () => {
|
|
5663
6226
|
if (!ifid || !playthroughId) return;
|
|
5664
6227
|
try {
|
|
5665
|
-
await createSave(ifid, playthroughId,
|
|
6228
|
+
await saveWithHooks(void 0, void 0, getSavePayload, (payload) => createSave(ifid, playthroughId, payload));
|
|
5666
6229
|
showStatus("Save created");
|
|
5667
6230
|
await refresh();
|
|
5668
6231
|
} catch {
|
|
@@ -5671,7 +6234,9 @@ function SaveManagerContent() {
|
|
|
5671
6234
|
};
|
|
5672
6235
|
const handleOverwrite = async (saveId) => {
|
|
5673
6236
|
try {
|
|
5674
|
-
await
|
|
6237
|
+
await saveWithHooks(void 0, void 0, getSavePayload, async (payload) => {
|
|
6238
|
+
if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
|
|
6239
|
+
});
|
|
5675
6240
|
showStatus("Save overwritten");
|
|
5676
6241
|
await refresh();
|
|
5677
6242
|
} catch {
|
|
@@ -5680,7 +6245,7 @@ function SaveManagerContent() {
|
|
|
5680
6245
|
};
|
|
5681
6246
|
const handleLoad = async (save) => {
|
|
5682
6247
|
try {
|
|
5683
|
-
loadFromPayload(save.payload);
|
|
6248
|
+
loadFromPayload(deserializePayload(save.payload));
|
|
5684
6249
|
showStatus("Game loaded");
|
|
5685
6250
|
if (closeDialog) setTimeout(closeDialog, 500);
|
|
5686
6251
|
} catch {
|
|
@@ -5969,7 +6534,6 @@ defineMacro({
|
|
|
5969
6534
|
merged: true,
|
|
5970
6535
|
render({ rawArgs }, ctx) {
|
|
5971
6536
|
const storyData = useStoryStore((s) => s.storyData);
|
|
5972
|
-
if (!storyData) return null;
|
|
5973
6537
|
const inline = /\binline\b/.test(rawArgs);
|
|
5974
6538
|
const nameExpr = rawArgs.replace(/\binline\b/, "").trim();
|
|
5975
6539
|
let passageName;
|
|
@@ -5979,14 +6543,15 @@ defineMacro({
|
|
|
5979
6543
|
} catch {
|
|
5980
6544
|
passageName = nameExpr.replace(/^["']|["']$/g, "");
|
|
5981
6545
|
}
|
|
5982
|
-
const passage = storyData
|
|
6546
|
+
const passage = storyData?.passages.get(passageName);
|
|
6547
|
+
const ast = ctx.hooks.useMemo(() => passage ? buildAST(tokenize(passage.content)) : null, [passage]);
|
|
6548
|
+
if (!storyData) return null;
|
|
5983
6549
|
if (passage) useStoryStore.getState().trackRender(passageName);
|
|
5984
|
-
if (!passage) return /* @__PURE__ */ jsx("span", {
|
|
6550
|
+
if (!passage || !ast) return /* @__PURE__ */ jsx("span", {
|
|
5985
6551
|
class: "error",
|
|
5986
6552
|
children: `{include${ctx.sourceLocation()}: passage "${passageName}" not found}`
|
|
5987
6553
|
});
|
|
5988
6554
|
const nobr = passage.tags.includes("nobr");
|
|
5989
|
-
const ast = buildAST(tokenize(passage.content));
|
|
5990
6555
|
const content = inline ? ctx.renderInlineNodes(ast) : ctx.renderNodes(ast, nobr ? { nobr: true } : void 0);
|
|
5991
6556
|
if (nobr) return ctx.wrap(ctx.h(NobrContext.Provider, { value: true }, content));
|
|
5992
6557
|
return ctx.wrap(content);
|
|
@@ -6269,8 +6834,25 @@ defineMacro({
|
|
|
6269
6834
|
});
|
|
6270
6835
|
//#endregion
|
|
6271
6836
|
//#region src/components/Passage.tsx
|
|
6837
|
+
/**
|
|
6838
|
+
* Parsed AST per passage. The renderer keys children by AST node identity,
|
|
6839
|
+
* so re-rendering a passage (e.g. PassageReady on every PassageDisplay
|
|
6840
|
+
* render) must reuse its AST — a fresh parse would remount every macro in
|
|
6841
|
+
* it and re-run its {set}/{do} side effects (#175).
|
|
6842
|
+
*/
|
|
6843
|
+
var astCache = /* @__PURE__ */ new WeakMap();
|
|
6844
|
+
function parsePassage(passage) {
|
|
6845
|
+
const cached = astCache.get(passage);
|
|
6846
|
+
if (cached && cached.content === passage.content) return cached.ast;
|
|
6847
|
+
const ast = buildAST(tokenize(passage.content));
|
|
6848
|
+
astCache.set(passage, {
|
|
6849
|
+
content: passage.content,
|
|
6850
|
+
ast
|
|
6851
|
+
});
|
|
6852
|
+
return ast;
|
|
6853
|
+
}
|
|
6272
6854
|
function renderPassageContent(passage) {
|
|
6273
|
-
return renderNodes(
|
|
6855
|
+
return renderNodes(parsePassage(passage), passage.tags.includes("nobr") ? { nobr: true } : void 0);
|
|
6274
6856
|
}
|
|
6275
6857
|
var CODE_PASSAGES = new Set([
|
|
6276
6858
|
"PassageReady",
|
|
@@ -6461,9 +7043,18 @@ defineMacro({
|
|
|
6461
7043
|
interpolate: true,
|
|
6462
7044
|
render(_props, ctx) {
|
|
6463
7045
|
const currentPassage = useStoryStore((s) => s.currentPassage);
|
|
7046
|
+
const navigationId = useStoryStore((s) => s.navigationId);
|
|
6464
7047
|
const storyData = useStoryStore((s) => s.storyData);
|
|
6465
7048
|
const renderDeferred = useStoryStore((s) => s.renderDeferred);
|
|
6466
|
-
const [
|
|
7049
|
+
const [displayed, setDisplayed] = useState({
|
|
7050
|
+
passage: currentPassage,
|
|
7051
|
+
id: navigationId
|
|
7052
|
+
});
|
|
7053
|
+
const displayedPassage = displayed.passage;
|
|
7054
|
+
const setDisplayedPassage = (passage) => setDisplayed({
|
|
7055
|
+
passage,
|
|
7056
|
+
id: navigationId
|
|
7057
|
+
});
|
|
6467
7058
|
const resolvedTypeRef = useRef("fade");
|
|
6468
7059
|
const prevHistoryLenRef = useRef(0);
|
|
6469
7060
|
const containerRef = useRef(null);
|
|
@@ -6483,7 +7074,7 @@ defineMacro({
|
|
|
6483
7074
|
const isFirstLoad = !displayedPassage;
|
|
6484
7075
|
const isRestart = history.length === 1 && historyIndex === 0 && prevLen > 1;
|
|
6485
7076
|
const isLoad = history.length > 1 && historyIndex === history.length - 1 && prevLen > 1 && Math.abs(history.length - prevLen) > 1;
|
|
6486
|
-
if (
|
|
7077
|
+
if (navigationId === displayed.id) return;
|
|
6487
7078
|
cancelTransition();
|
|
6488
7079
|
const targetPassage = storyData?.passages.get(currentPassage);
|
|
6489
7080
|
if (!targetPassage) {
|
|
@@ -6553,7 +7144,7 @@ defineMacro({
|
|
|
6553
7144
|
return;
|
|
6554
7145
|
}
|
|
6555
7146
|
setDisplayedPassage(currentPassage);
|
|
6556
|
-
}, [
|
|
7147
|
+
}, [navigationId]);
|
|
6557
7148
|
const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
|
|
6558
7149
|
if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
|
|
6559
7150
|
class: "error",
|
|
@@ -6570,13 +7161,13 @@ defineMacro({
|
|
|
6570
7161
|
children: [!renderDeferred && readyPassage && /* @__PURE__ */ jsx("div", {
|
|
6571
7162
|
hidden: true,
|
|
6572
7163
|
children: renderPassageContent(readyPassage)
|
|
6573
|
-
}, `ready-${
|
|
7164
|
+
}, `ready-${navigationId}`), /* @__PURE__ */ jsx("div", {
|
|
6574
7165
|
class: "passage-container",
|
|
6575
7166
|
ref: containerRef,
|
|
6576
7167
|
children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
|
|
6577
7168
|
passage: effectivePassage,
|
|
6578
7169
|
dataTransition: renderDeferred ? "none" : resolvedTypeRef.current
|
|
6579
|
-
}, renderDeferred ? "loading" :
|
|
7170
|
+
}, renderDeferred ? "loading" : `nav-${displayed.id}`)
|
|
6580
7171
|
})]
|
|
6581
7172
|
});
|
|
6582
7173
|
}
|
|
@@ -6623,7 +7214,7 @@ defineMacro({
|
|
|
6623
7214
|
const registered = hooks.useRef(false);
|
|
6624
7215
|
if (!registered.current) {
|
|
6625
7216
|
registered.current = true;
|
|
6626
|
-
|
|
7217
|
+
addMacroTrigger(condition, options);
|
|
6627
7218
|
}
|
|
6628
7219
|
return null;
|
|
6629
7220
|
}
|
|
@@ -6697,7 +7288,8 @@ function boot() {
|
|
|
6697
7288
|
throw new Error(`spindle: ${msg}`);
|
|
6698
7289
|
}
|
|
6699
7290
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
6700
|
-
const
|
|
7291
|
+
const storeVarMacros = getMacroRegistry().filter((m) => m.storeVar).map((m) => m.name);
|
|
7292
|
+
const errors = validatePassages(storyData.passages, schema, storeVarMacros);
|
|
6701
7293
|
let transientDefaults = {};
|
|
6702
7294
|
const storyTransientsPassage = storyData.passages.get("StoryTransients");
|
|
6703
7295
|
if (storyTransientsPassage) {
|
|
@@ -6740,21 +7332,14 @@ function boot() {
|
|
|
6740
7332
|
if (isBlock) registerBlockMacro(widgetName);
|
|
6741
7333
|
}
|
|
6742
7334
|
}
|
|
6743
|
-
let
|
|
7335
|
+
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
6744
7336
|
useStoryStore.subscribe((state) => {
|
|
6745
|
-
if (state.
|
|
6746
|
-
|
|
7337
|
+
if (state.navigationId !== prevNavigationId) {
|
|
7338
|
+
prevNavigationId = state.navigationId;
|
|
6747
7339
|
resetIdCounters();
|
|
6748
7340
|
}
|
|
6749
7341
|
});
|
|
6750
|
-
|
|
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
|
-
});
|
|
7342
|
+
connectTriggersToStore();
|
|
6758
7343
|
const storyInterfacePassage = storyData.passages.get("StoryInterface");
|
|
6759
7344
|
if (storyInterfacePassage && !storyInterfacePassage.content.includes("{passage}")) console.warn("spindle: StoryInterface passage does not contain {passage}. The current passage will not be displayed.");
|
|
6760
7345
|
const root = document.getElementById("root");
|