@rohal12/spindle 0.51.3 → 0.51.4
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 +1294 -737
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +14 -6
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +37 -3
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +70 -25
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
|
@@ -118,8 +118,11 @@ function parseHtmlAttributes(input, j) {
|
|
|
118
118
|
while (j < input.length && /[a-zA-Z0-9_\-:@]/.test(input[j])) j++;
|
|
119
119
|
const attrName = input.slice(attrStart, j);
|
|
120
120
|
if (!attrName) break;
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
let eqIdx = j;
|
|
122
|
+
while (eqIdx < input.length && /\s/.test(input[eqIdx])) eqIdx++;
|
|
123
|
+
if (input[eqIdx] === "=") {
|
|
124
|
+
j = eqIdx + 1;
|
|
125
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
123
126
|
if (input[j] === "\"" || input[j] === "'") {
|
|
124
127
|
const quote = input[j];
|
|
125
128
|
j++;
|
|
@@ -260,16 +263,23 @@ function tokenize(input) {
|
|
|
260
263
|
textStart = closeEnd;
|
|
261
264
|
}
|
|
262
265
|
while (i < input.length) {
|
|
263
|
-
if (input[i] === "\\"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
266
|
+
if (input[i] === "\\") {
|
|
267
|
+
let k = i + 1;
|
|
268
|
+
while (input[k] === "\\") k++;
|
|
269
|
+
const next = input[k];
|
|
270
|
+
if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
|
|
271
|
+
flushText(k - 1);
|
|
272
|
+
tokens.push({
|
|
273
|
+
type: "text",
|
|
274
|
+
value: next,
|
|
275
|
+
start: k - 1,
|
|
276
|
+
end: k + 1
|
|
277
|
+
});
|
|
278
|
+
i = k + 1;
|
|
279
|
+
textStart = i;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
i = k;
|
|
273
283
|
continue;
|
|
274
284
|
}
|
|
275
285
|
if (input[i] === "[" && input[i + 1] === "[") {
|
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -313,3 +313,79 @@ export function deserialize<T>(value: T): T {
|
|
|
313
313
|
|
|
314
314
|
return deser(value) as T;
|
|
315
315
|
}
|
|
316
|
+
|
|
317
|
+
// --- Validate ---
|
|
318
|
+
|
|
319
|
+
function isDataRecord(value: unknown): value is Record<string, unknown> {
|
|
320
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Whether `value` is serialized data that deserialize() can restore: every
|
|
325
|
+
* tagged value has the shape serialize() writes for its tag (a valid ISO
|
|
326
|
+
* date, a compilable RegExp, Map entries as `[key, value]` pairs, Set
|
|
327
|
+
* entries as an array, class data as an object), at any depth. Use it on
|
|
328
|
+
* data from outside the running story, such as an imported save, before
|
|
329
|
+
* storing it. Tags of unregistered classes pass; they load as plain objects.
|
|
330
|
+
*/
|
|
331
|
+
export function isDeserializable(value: unknown): boolean {
|
|
332
|
+
// Objects on the current path; JSON can't hold cycles, and deserialize()
|
|
333
|
+
// can't restore them
|
|
334
|
+
const path = new Set<object>();
|
|
335
|
+
|
|
336
|
+
function check(val: unknown): boolean {
|
|
337
|
+
if (val === null || typeof val !== 'object') return true;
|
|
338
|
+
if (path.has(val)) return false;
|
|
339
|
+
path.add(val);
|
|
340
|
+
const ok = checkObject(val);
|
|
341
|
+
path.delete(val);
|
|
342
|
+
return ok;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function checkObject(val: object): boolean {
|
|
346
|
+
if (Array.isArray(val)) return val.every(check);
|
|
347
|
+
|
|
348
|
+
const obj = val as Record<string, unknown>;
|
|
349
|
+
if (!(CLASS_TAG in obj && DATA_TAG in obj)) {
|
|
350
|
+
return Object.keys(obj).every((key) => check(obj[key]));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const name = obj[CLASS_TAG];
|
|
354
|
+
const data = obj[DATA_TAG];
|
|
355
|
+
if (typeof name !== 'string' || !isDataRecord(data)) return false;
|
|
356
|
+
|
|
357
|
+
switch (name) {
|
|
358
|
+
case '__Date__':
|
|
359
|
+
return (
|
|
360
|
+
typeof data.iso === 'string' &&
|
|
361
|
+
!Number.isNaN(new Date(data.iso).getTime())
|
|
362
|
+
);
|
|
363
|
+
case '__RegExp__':
|
|
364
|
+
if (typeof data.source !== 'string' || typeof data.flags !== 'string')
|
|
365
|
+
return false;
|
|
366
|
+
try {
|
|
367
|
+
new RegExp(data.source, data.flags);
|
|
368
|
+
return true;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
case '__Map__':
|
|
373
|
+
return (
|
|
374
|
+
Array.isArray(data.entries) &&
|
|
375
|
+
data.entries.every(
|
|
376
|
+
(entry: unknown) =>
|
|
377
|
+
Array.isArray(entry) &&
|
|
378
|
+
entry.length === 2 &&
|
|
379
|
+
check(entry[0]) &&
|
|
380
|
+
check(entry[1]),
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
case '__Set__':
|
|
384
|
+
return Array.isArray(data.entries) && data.entries.every(check);
|
|
385
|
+
default:
|
|
386
|
+
return Object.keys(data).every((key) => check(data[key]));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return check(value);
|
|
391
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
|
+
import { readWholeQuoted } from './arg-utils';
|
|
4
5
|
import {
|
|
5
6
|
renderNodes,
|
|
6
7
|
LocalsUpdateContext,
|
|
@@ -14,7 +15,9 @@ defineMacro({
|
|
|
14
15
|
block: true,
|
|
15
16
|
interpolate: true,
|
|
16
17
|
render({ rawArgs, children = [] }, ctx) {
|
|
17
|
-
const
|
|
18
|
+
const text =
|
|
19
|
+
readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, '');
|
|
20
|
+
const label = ctx.resolve?.(text) ?? rawArgs;
|
|
18
21
|
const nobr = useContext(NobrContext);
|
|
19
22
|
|
|
20
23
|
const handleClick = () => {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { readWholeQuoted, stripLooseQuotes } from './arg-utils';
|
|
2
3
|
|
|
3
4
|
function parseLabel(rawArgs: string): string {
|
|
4
|
-
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+
|
|
5
|
-
|
|
5
|
+
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+?)\s*$/);
|
|
6
|
+
if (!match) return '';
|
|
7
|
+
const rest = match[1]!;
|
|
8
|
+
return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
defineMacro({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { PassageDialog } from '../PassageDialog';
|
|
3
|
+
import { readWholeQuoted } from './arg-utils';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
5
6
|
name: 'dialog',
|
|
@@ -11,7 +12,9 @@ defineMacro({
|
|
|
11
12
|
const noclose = /\bnoclose\s*$/.test(rawArgs);
|
|
12
13
|
const labelRaw = rawArgs.replace(/\bnoclose\s*$/, '').trim();
|
|
13
14
|
const label =
|
|
14
|
-
ctx.resolve?.(
|
|
15
|
+
ctx.resolve?.(
|
|
16
|
+
readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, ''),
|
|
17
|
+
) ?? labelRaw;
|
|
15
18
|
const passageName = ctx
|
|
16
19
|
.collectText(children)
|
|
17
20
|
.trim()
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
LocalsValuesContext,
|
|
10
10
|
LocalsUpdateContext,
|
|
11
11
|
NobrContext,
|
|
12
|
+
InlineContext,
|
|
12
13
|
renderNodes,
|
|
13
14
|
} from '../../markup/render';
|
|
14
15
|
import { defineMacro } from '../../define-macro';
|
|
15
16
|
import { MacroError } from './MacroError';
|
|
17
|
+
import { stableKey } from '../../utils/stable-key';
|
|
16
18
|
import type { ASTNode } from '../../markup/ast';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -97,11 +99,12 @@ function ForIteration({
|
|
|
97
99
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
98
100
|
|
|
99
101
|
const nobr = useContext(NobrContext);
|
|
102
|
+
const inline = useContext(InlineContext);
|
|
100
103
|
|
|
101
104
|
return (
|
|
102
105
|
<LocalsUpdateContext.Provider value={updater}>
|
|
103
106
|
<LocalsValuesContext.Provider value={localState}>
|
|
104
|
-
{renderNodes(children, { nobr, locals: localState })}
|
|
107
|
+
{renderNodes(children, { nobr, inline, locals: localState })}
|
|
105
108
|
</LocalsValuesContext.Provider>
|
|
106
109
|
</LocalsUpdateContext.Provider>
|
|
107
110
|
);
|
|
@@ -151,7 +154,7 @@ defineMacro({
|
|
|
151
154
|
|
|
152
155
|
const content = list.map((item, i) => (
|
|
153
156
|
<ForIteration
|
|
154
|
-
key={`${i}-${
|
|
157
|
+
key={`${i}-${stableKey(item)}`}
|
|
155
158
|
parentValues={parentValues}
|
|
156
159
|
itemVar={itemVar}
|
|
157
160
|
itemValue={item}
|
|
@@ -3,6 +3,7 @@ import { tokenize } from '../../markup/tokenizer';
|
|
|
3
3
|
import { buildAST } from '../../markup/ast';
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
|
+
import { isWhitespace, topLevelIndices } from './arg-utils';
|
|
6
7
|
|
|
7
8
|
const FLAG = 'inline';
|
|
8
9
|
|
|
@@ -19,27 +20,16 @@ function parseIncludeArgs(rawArgs: string): {
|
|
|
19
20
|
const trimmed = rawArgs.trim();
|
|
20
21
|
|
|
21
22
|
// First and last whitespace runs at depth 0, as [start, end) offsets.
|
|
23
|
+
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
22
24
|
let first: [number, number] | null = null;
|
|
23
25
|
let last: [number, number] | null = null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
if (ch === '"' || ch === "'" || ch === '`') inString = ch;
|
|
34
|
-
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
35
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
36
|
-
else if (depth === 0 && /\s/.test(ch)) {
|
|
37
|
-
let end = i + 1;
|
|
38
|
-
while (end < trimmed.length && /\s/.test(trimmed[end]!)) end++;
|
|
39
|
-
last = [i, end];
|
|
40
|
-
first ??= last;
|
|
41
|
-
i = end - 1;
|
|
42
|
-
}
|
|
26
|
+
if (spaces.length > 0) {
|
|
27
|
+
let s = 0;
|
|
28
|
+
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s]! + 1) s++;
|
|
29
|
+
first = [spaces[0]!, spaces[s]! + 1];
|
|
30
|
+
let e = spaces.length - 1;
|
|
31
|
+
while (e > 0 && spaces[e - 1] === spaces[e]! - 1) e--;
|
|
32
|
+
last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
|
|
43
33
|
}
|
|
44
34
|
|
|
45
35
|
// Next to a binary operator the word is an operand (`"a" + inline`).
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '../../markup/render';
|
|
10
10
|
import { defineMacro } from '../../define-macro';
|
|
11
11
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
12
|
+
import { unescapeQuoted } from './arg-utils';
|
|
12
13
|
|
|
13
14
|
function parseArgs(rawArgs: string): {
|
|
14
15
|
display: string;
|
|
@@ -20,7 +21,7 @@ function parseArgs(rawArgs: string): {
|
|
|
20
21
|
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
21
22
|
let m;
|
|
22
23
|
while ((m = re.exec(rawArgs)) !== null) {
|
|
23
|
-
parts.push(m[2]
|
|
24
|
+
parts.push(unescapeQuoted(m[2]!));
|
|
24
25
|
}
|
|
25
26
|
if (parts.length >= 2) {
|
|
26
27
|
return { display: parts[0]!, passage: parts[1]! };
|
|
@@ -1,63 +1,41 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { MacroError } from './MacroError';
|
|
3
|
+
import { isWhitespace, readWholeQuoted, splitTopLevel } from './arg-utils';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
|
|
7
|
+
* whitespace outside string and template literals and bracket pairs; a
|
|
8
|
+
* trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
|
|
9
|
+
* `\'` and `\\` escapes.
|
|
10
|
+
*/
|
|
11
|
+
export function parseMeterArgs(rawArgs: string): {
|
|
5
12
|
currentExpr: string;
|
|
6
13
|
maxExpr: string;
|
|
7
14
|
labelMode: string;
|
|
8
15
|
} {
|
|
9
|
-
const
|
|
16
|
+
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
10
17
|
|
|
11
|
-
// Extract quoted label mode from the end if present
|
|
12
18
|
let labelMode = '';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
labelMode =
|
|
17
|
-
|
|
19
|
+
const label =
|
|
20
|
+
tokens.length >= 2 ? readWholeQuoted(tokens[tokens.length - 1]!) : null;
|
|
21
|
+
if (label !== null) {
|
|
22
|
+
labelMode = label;
|
|
23
|
+
tokens.pop();
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
const exprs = splitExpressions(rest);
|
|
22
|
-
if (exprs.length < 2) {
|
|
26
|
+
if (tokens.length < 2) {
|
|
23
27
|
throw new Error(
|
|
24
28
|
'meter requires two arguments: {meter currentExpr maxExpr}',
|
|
25
29
|
);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
return {
|
|
29
|
-
currentExpr:
|
|
30
|
-
maxExpr:
|
|
33
|
+
currentExpr: tokens[0]!,
|
|
34
|
+
maxExpr: tokens.slice(1).join(' '),
|
|
31
35
|
labelMode,
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
function splitExpressions(input: string): string[] {
|
|
36
|
-
const result: string[] = [];
|
|
37
|
-
let current = '';
|
|
38
|
-
let depth = 0;
|
|
39
|
-
|
|
40
|
-
for (let i = 0; i < input.length; i++) {
|
|
41
|
-
const ch = input[i]!;
|
|
42
|
-
if (ch === '(' || ch === '[') {
|
|
43
|
-
depth++;
|
|
44
|
-
current += ch;
|
|
45
|
-
} else if (ch === ')' || ch === ']') {
|
|
46
|
-
depth--;
|
|
47
|
-
current += ch;
|
|
48
|
-
} else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
|
|
49
|
-
result.push(current);
|
|
50
|
-
current = '';
|
|
51
|
-
// Skip additional whitespace
|
|
52
|
-
while (i + 1 < input.length && /\s/.test(input[i + 1]!)) i++;
|
|
53
|
-
} else {
|
|
54
|
-
current += ch;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (current.length > 0) result.push(current);
|
|
58
|
-
return result;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
39
|
function formatLabel(
|
|
62
40
|
current: number,
|
|
63
41
|
max: number,
|
|
@@ -76,7 +54,7 @@ defineMacro({
|
|
|
76
54
|
merged: true,
|
|
77
55
|
render({ rawArgs }, ctx) {
|
|
78
56
|
try {
|
|
79
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
57
|
+
const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
|
|
80
58
|
const current = Number(ctx.evaluate!(currentExpr));
|
|
81
59
|
const max = Number(ctx.evaluate!(maxExpr));
|
|
82
60
|
const pct =
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import {
|
|
3
|
+
isWhitespace,
|
|
4
|
+
readQuoted,
|
|
5
|
+
readWholeQuoted,
|
|
6
|
+
stripLooseQuotes,
|
|
7
|
+
} from './arg-utils';
|
|
2
8
|
|
|
3
9
|
function parseRadioArgs(rawArgs: string): { value: string; label: string } {
|
|
10
|
+
// $var "value" label: a quoted value accepts \" \' and \\ escapes and
|
|
11
|
+
// may contain the other quote kind.
|
|
12
|
+
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
13
|
+
if (head) {
|
|
14
|
+
const rest = rawArgs.slice(head[0].length).trim();
|
|
15
|
+
const value = readQuoted(rest, 0);
|
|
16
|
+
if (
|
|
17
|
+
value &&
|
|
18
|
+
(value.end === rest.length || isWhitespace(rest[value.end]!))
|
|
19
|
+
) {
|
|
20
|
+
const labelRaw = rest.slice(value.end).trim();
|
|
21
|
+
const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
|
|
22
|
+
return { value: value.value, label };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
4
26
|
const match = rawArgs.match(
|
|
5
27
|
/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
|
|
6
28
|
);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
useRef,
|
|
6
6
|
useContext,
|
|
7
7
|
} from 'preact/hooks';
|
|
8
|
-
import { useStoryStore } from '../../store';
|
|
8
|
+
import { useStoryStore, resolvePlaythroughId } from '../../store';
|
|
9
9
|
import { isSaveExport, type SaveRecord } from '../../saves/types';
|
|
10
10
|
import {
|
|
11
11
|
getSavesGrouped,
|
|
@@ -106,11 +106,16 @@ export function SaveManagerContent() {
|
|
|
106
106
|
|
|
107
107
|
// Dialog saves are not slot saves, so the save hooks get no slot.
|
|
108
108
|
const handleNewSave = async () => {
|
|
109
|
-
if (!ifid
|
|
109
|
+
if (!ifid) return;
|
|
110
|
+
// The playthrough current at the click, which a restart may have
|
|
111
|
+
// switched since this render
|
|
112
|
+
const playthrough = resolvePlaythroughId();
|
|
110
113
|
try {
|
|
111
|
-
await saveWithHooks(undefined, undefined, beginSave, (payload) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
115
|
+
const ptId = await playthrough;
|
|
116
|
+
if (!ptId) throw new Error('No playthrough');
|
|
117
|
+
return createSave(ifid, ptId, payload);
|
|
118
|
+
});
|
|
114
119
|
showStatus('Save created');
|
|
115
120
|
await refresh();
|
|
116
121
|
} catch {
|
|
@@ -119,9 +124,12 @@ export function SaveManagerContent() {
|
|
|
119
124
|
};
|
|
120
125
|
|
|
121
126
|
const handleOverwrite = async (saveId: string) => {
|
|
127
|
+
const playthrough = resolvePlaythroughId();
|
|
122
128
|
try {
|
|
123
129
|
await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
|
|
124
|
-
|
|
130
|
+
// Only the current playthrough's saves offer "Save Here"
|
|
131
|
+
const ptId = (await playthrough) || undefined;
|
|
132
|
+
if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
|
|
125
133
|
throw new Error('Save not found');
|
|
126
134
|
}
|
|
127
135
|
});
|
|
@@ -1,51 +1,100 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { addMacroTrigger, removeTrigger } from '../../triggers';
|
|
3
3
|
import type { WatchOptions } from '../../triggers';
|
|
4
|
+
import { isWhitespace, readQuoted, readWholeQuoted } from './arg-utils';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
render(props, ctx) {
|
|
8
|
-
const { hooks } = ctx;
|
|
6
|
+
const WORD_RE = /\w+/y;
|
|
7
|
+
const DIGITS_RE = /\d+/y;
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
10
|
+
function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
11
|
+
re.lastIndex = pos;
|
|
12
|
+
return re.exec(src)?.[0] ?? null;
|
|
13
|
+
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Parse `{watch "condition" keyword value …}`: a quoted condition followed
|
|
17
|
+
* by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
|
|
18
|
+
* `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
19
|
+
* Returns `null` when the arguments do not start with a quoted condition.
|
|
20
|
+
*/
|
|
21
|
+
export function parseWatchArgs(
|
|
22
|
+
rawArgs: string,
|
|
23
|
+
): { condition: string; options: WatchOptions } | null {
|
|
24
|
+
const raw = rawArgs.trim();
|
|
25
|
+
const cond = readQuoted(raw, 0);
|
|
26
|
+
if (!cond) return null;
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
case 'once':
|
|
44
|
-
options.once = true;
|
|
45
|
-
break;
|
|
28
|
+
const options: WatchOptions = {};
|
|
29
|
+
let i = cond.end;
|
|
30
|
+
while (i < raw.length) {
|
|
31
|
+
const key = matchAt(WORD_RE, raw, i);
|
|
32
|
+
if (!key) {
|
|
33
|
+
// Skip whitespace and stray characters, or a stray quoted string.
|
|
34
|
+
i = readQuoted(raw, i)?.end ?? i + 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
i += key.length;
|
|
38
|
+
|
|
39
|
+
// A value is a quoted string or a digit run, after whitespace.
|
|
40
|
+
let val: string | undefined;
|
|
41
|
+
let j = i;
|
|
42
|
+
while (j < raw.length && isWhitespace(raw[j]!)) j++;
|
|
43
|
+
if (j > i) {
|
|
44
|
+
const quoted = readQuoted(raw, j);
|
|
45
|
+
const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
|
|
46
|
+
if (quoted) {
|
|
47
|
+
val = quoted.value;
|
|
48
|
+
i = quoted.end;
|
|
49
|
+
} else if (digits) {
|
|
50
|
+
val = digits;
|
|
51
|
+
i = j + digits.length;
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
switch (key) {
|
|
56
|
+
case 'goto':
|
|
57
|
+
options.goto = val;
|
|
58
|
+
break;
|
|
59
|
+
case 'dialog':
|
|
60
|
+
options.dialog = val;
|
|
61
|
+
break;
|
|
62
|
+
case 'run':
|
|
63
|
+
options.run = val;
|
|
64
|
+
break;
|
|
65
|
+
case 'name':
|
|
66
|
+
options.name = val;
|
|
67
|
+
break;
|
|
68
|
+
case 'priority':
|
|
69
|
+
options.priority = val ? Number(val) : 0;
|
|
70
|
+
break;
|
|
71
|
+
case 'once':
|
|
72
|
+
options.once = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { condition: cond.value, options };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
|
|
82
|
+
* same escapes as `{watch}`, or a bare name.
|
|
83
|
+
*/
|
|
84
|
+
export function parseUnwatchName(rawArgs: string): string {
|
|
85
|
+
const raw = rawArgs.trim();
|
|
86
|
+
return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
defineMacro({
|
|
90
|
+
name: 'watch',
|
|
91
|
+
render(props, ctx) {
|
|
92
|
+
const { hooks } = ctx;
|
|
93
|
+
|
|
94
|
+
const parsed = parseWatchArgs(props.rawArgs);
|
|
95
|
+
if (!parsed) return null;
|
|
96
|
+
const { condition, options } = parsed;
|
|
97
|
+
|
|
49
98
|
// Register during render (like {set}) — triggers survive navigation
|
|
50
99
|
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
51
100
|
// Remounts (revisits, re-rendered branches) keep the registered watcher.
|
|
@@ -62,8 +111,7 @@ defineMacro({
|
|
|
62
111
|
defineMacro({
|
|
63
112
|
name: 'unwatch',
|
|
64
113
|
render(props, ctx) {
|
|
65
|
-
const
|
|
66
|
-
const name = raw.replace(/^['"]|['"]$/g, '');
|
|
114
|
+
const name = parseUnwatchName(props.rawArgs);
|
|
67
115
|
|
|
68
116
|
const ran = ctx.hooks.useRef(false);
|
|
69
117
|
if (!ran.current) {
|