@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +852 -254
- 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 +266 -87
- package/src/story-api.ts +30 -28
- 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/types/index.d.ts +16 -6
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,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
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
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",
|
|
3508
|
-
const
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
3534
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
}
|
|
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
|
-
|
|
4330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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 [
|
|
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 (
|
|
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
|
-
}, [
|
|
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-${
|
|
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" :
|
|
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
|
-
|
|
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
|
|
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
|
|
7348
|
+
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
6744
7349
|
useStoryStore.subscribe((state) => {
|
|
6745
|
-
if (state.
|
|
6746
|
-
|
|
7350
|
+
if (state.navigationId !== prevNavigationId) {
|
|
7351
|
+
prevNavigationId = state.navigationId;
|
|
6747
7352
|
resetIdCounters();
|
|
6748
7353
|
}
|
|
6749
7354
|
});
|
|
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
|
-
});
|
|
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");
|