@rohal12/spindle 0.51.4 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/story-api.ts
CHANGED
|
@@ -18,20 +18,13 @@ import type {
|
|
|
18
18
|
import {
|
|
19
19
|
setTitleGenerator,
|
|
20
20
|
getStorageInfo as _getStorageInfo,
|
|
21
|
-
clearGameData as _clearGameData,
|
|
22
|
-
clearAllData as _clearAllData,
|
|
23
|
-
deletePlaythroughData,
|
|
24
|
-
populateKnownSaves,
|
|
25
21
|
} from './saves/save-manager';
|
|
26
22
|
import { getBackendType } from './saves/storage';
|
|
27
23
|
import { registerClass } from './class-registry';
|
|
28
|
-
import {
|
|
29
|
-
frozenCopy,
|
|
30
|
-
getActiveMutationScope,
|
|
31
|
-
mirrorWriteToActiveScopes,
|
|
32
|
-
runWithCommittedMutations,
|
|
33
|
-
} from './execute-mutation';
|
|
24
|
+
import { frozenCopy, getActiveMutationScope } from './execute-mutation';
|
|
34
25
|
import { getByPath, setByPath } from './utils/object-path';
|
|
26
|
+
import { checkVariableName } from './utils/namespace';
|
|
27
|
+
import { countOf } from './utils/counts';
|
|
35
28
|
import { defineMacro } from './define-macro';
|
|
36
29
|
import type { MacroDefinition } from './define-macro';
|
|
37
30
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
@@ -85,8 +78,9 @@ let variableChangedSubActive = false;
|
|
|
85
78
|
function ensureVariableChangedSubscription(): void {
|
|
86
79
|
if (variableChangedSubActive) return;
|
|
87
80
|
variableChangedSubActive = true;
|
|
88
|
-
|
|
89
|
-
let
|
|
81
|
+
// Store namespaces are immutable snapshots: keep the references
|
|
82
|
+
let prevVars = useStoryStore.getState().variables;
|
|
83
|
+
let prevTrans = useStoryStore.getState().transient;
|
|
90
84
|
useStoryStore.subscribe((state) => {
|
|
91
85
|
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
|
92
86
|
let hasChanges = false;
|
|
@@ -115,8 +109,8 @@ function ensureVariableChangedSubscription(): void {
|
|
|
115
109
|
}
|
|
116
110
|
}
|
|
117
111
|
|
|
118
|
-
prevVars =
|
|
119
|
-
prevTrans =
|
|
112
|
+
prevVars = state.variables;
|
|
113
|
+
prevTrans = state.transient;
|
|
120
114
|
if (hasChanges) {
|
|
121
115
|
emit('variableChanged', changed);
|
|
122
116
|
}
|
|
@@ -237,15 +231,17 @@ export function _resetDeclaredVariables(): void {
|
|
|
237
231
|
/**
|
|
238
232
|
* Split an API variable name into namespace and key. Accepts the bare name
|
|
239
233
|
* (`hp`), the `$` sigil authors use in passages (`$hp`), and `%` for
|
|
240
|
-
* transients (`%npcs`). Dot-paths are kept in the key.
|
|
234
|
+
* transients (`%npcs`). Dot-paths are kept in the key. A variable named
|
|
235
|
+
* `__proto__` throws a TypeError (see utils/namespace.ts).
|
|
241
236
|
*/
|
|
242
237
|
function parseName(name: string): {
|
|
243
238
|
isTransient: boolean;
|
|
244
239
|
key: string;
|
|
245
240
|
} {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
241
|
+
const isTransient = name.startsWith('%');
|
|
242
|
+
const key = isTransient || name.startsWith('$') ? name.slice(1) : name;
|
|
243
|
+
checkVariableName(key.split('.')[0]!, name);
|
|
244
|
+
return { isTransient, key };
|
|
249
245
|
}
|
|
250
246
|
|
|
251
247
|
function warnIfUndeclared(isTransient: boolean, key: string): void {
|
|
@@ -301,51 +297,36 @@ function createStoryAPI(): StoryAPI {
|
|
|
301
297
|
const { isTransient, key } = parseName(name);
|
|
302
298
|
warnIfUndeclared(isTransient, key);
|
|
303
299
|
}
|
|
304
|
-
// One store update for all keys, so watchers see them together
|
|
300
|
+
// One store update for all keys, so watchers see them together. Made
|
|
301
|
+
// while mutation code runs ({do}, ctx.mutate, watcher run actions), it
|
|
302
|
+
// follows the code's own pending writes (program order, #215): see
|
|
303
|
+
// routeStoreUpdate.
|
|
305
304
|
useStoryStore.getState().updateVariables((draft) => {
|
|
306
305
|
for (const [k, v] of entries) setOne(draft, k, v);
|
|
307
|
-
// Mutation code running now ({do}, ctx.mutate, watcher run actions)
|
|
308
|
-
// works on copies of the namespaces and commits the paths it changed
|
|
309
|
-
// when it finishes. Apply the write to those copies too, so the code
|
|
310
|
-
// sees it and its commit keeps it in program order (#215). This runs
|
|
311
|
-
// inside the update, before watchers it triggers write.
|
|
312
|
-
for (const [k, v] of entries) {
|
|
313
|
-
const { isTransient, key } = parseName(k);
|
|
314
|
-
mirrorWriteToActiveScopes(
|
|
315
|
-
draft,
|
|
316
|
-
isTransient ? 'transient' : 'variables',
|
|
317
|
-
key.split('.'),
|
|
318
|
-
v,
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
306
|
});
|
|
322
307
|
},
|
|
323
308
|
|
|
324
|
-
// Called from running mutation code, these commit the
|
|
325
|
-
// far before they record, replace or save state, and
|
|
326
|
-
// from the state they leave (see
|
|
309
|
+
// Called from running mutation code, these store actions commit the
|
|
310
|
+
// code's writes so far before they record, replace or save state, and
|
|
311
|
+
// the code goes on from the state they leave (see storyStateGuard).
|
|
327
312
|
goto(passageName: string): void {
|
|
328
|
-
|
|
329
|
-
useStoryStore.getState().navigate(passageName),
|
|
330
|
-
);
|
|
313
|
+
useStoryStore.getState().navigate(passageName);
|
|
331
314
|
},
|
|
332
315
|
|
|
333
316
|
back(): void {
|
|
334
|
-
|
|
317
|
+
useStoryStore.getState().goBack();
|
|
335
318
|
},
|
|
336
319
|
|
|
337
320
|
forward(): void {
|
|
338
|
-
|
|
321
|
+
useStoryStore.getState().goForward();
|
|
339
322
|
},
|
|
340
323
|
|
|
341
324
|
restart(): void {
|
|
342
|
-
|
|
325
|
+
useStoryStore.getState().restart();
|
|
343
326
|
},
|
|
344
327
|
|
|
345
328
|
save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
|
|
346
|
-
return
|
|
347
|
-
useStoryStore.getState().save(slot, custom),
|
|
348
|
-
);
|
|
329
|
+
return useStoryStore.getState().save(slot, custom);
|
|
349
330
|
},
|
|
350
331
|
|
|
351
332
|
load(slot?: string): Promise<void> {
|
|
@@ -378,7 +359,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
378
359
|
|
|
379
360
|
visited(name?: string): number {
|
|
380
361
|
const state = useStoryStore.getState();
|
|
381
|
-
return state.visitCounts
|
|
362
|
+
return countOf(state.visitCounts, name ?? state.currentPassage);
|
|
382
363
|
},
|
|
383
364
|
|
|
384
365
|
hasVisited(name?: string): boolean {
|
|
@@ -387,17 +368,17 @@ function createStoryAPI(): StoryAPI {
|
|
|
387
368
|
|
|
388
369
|
hasVisitedAny(...names: string[]): boolean {
|
|
389
370
|
const { visitCounts } = useStoryStore.getState();
|
|
390
|
-
return names.some((n) => (visitCounts
|
|
371
|
+
return names.some((n) => countOf(visitCounts, n) > 0);
|
|
391
372
|
},
|
|
392
373
|
|
|
393
374
|
hasVisitedAll(...names: string[]): boolean {
|
|
394
375
|
const { visitCounts } = useStoryStore.getState();
|
|
395
|
-
return names.every((n) => (visitCounts
|
|
376
|
+
return names.every((n) => countOf(visitCounts, n) > 0);
|
|
396
377
|
},
|
|
397
378
|
|
|
398
379
|
rendered(name?: string): number {
|
|
399
380
|
const state = useStoryStore.getState();
|
|
400
|
-
return state.renderCounts
|
|
381
|
+
return countOf(state.renderCounts, name ?? state.currentPassage);
|
|
401
382
|
},
|
|
402
383
|
|
|
403
384
|
hasRendered(name?: string): boolean {
|
|
@@ -406,12 +387,12 @@ function createStoryAPI(): StoryAPI {
|
|
|
406
387
|
|
|
407
388
|
hasRenderedAny(...names: string[]): boolean {
|
|
408
389
|
const { renderCounts } = useStoryStore.getState();
|
|
409
|
-
return names.some((n) => (renderCounts
|
|
390
|
+
return names.some((n) => countOf(renderCounts, n) > 0);
|
|
410
391
|
},
|
|
411
392
|
|
|
412
393
|
hasRenderedAll(...names: string[]): boolean {
|
|
413
394
|
const { renderCounts } = useStoryStore.getState();
|
|
414
|
-
return names.every((n) => (renderCounts
|
|
395
|
+
return names.every((n) => countOf(renderCounts, n) > 0);
|
|
415
396
|
},
|
|
416
397
|
|
|
417
398
|
currentPassage(): Passage | undefined {
|
|
@@ -483,32 +464,16 @@ function createStoryAPI(): StoryAPI {
|
|
|
483
464
|
return { usage: 0, quota: 0, estimateSupported: false };
|
|
484
465
|
},
|
|
485
466
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (!ifid) return;
|
|
489
|
-
await _clearGameData(ifid);
|
|
490
|
-
useStoryStore.setState((state) => {
|
|
491
|
-
state.knownSaves = {};
|
|
492
|
-
});
|
|
493
|
-
useStoryStore.getState().restart();
|
|
467
|
+
clearGameData(): Promise<void> {
|
|
468
|
+
return useStoryStore.getState().clearGameData();
|
|
494
469
|
},
|
|
495
470
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
useStoryStore.setState((state) => {
|
|
499
|
-
state.knownSaves = {};
|
|
500
|
-
});
|
|
501
|
-
useStoryStore.getState().restart();
|
|
471
|
+
clearAllData(): Promise<void> {
|
|
472
|
+
return useStoryStore.getState().clearAllData();
|
|
502
473
|
},
|
|
503
474
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (!storyData) return;
|
|
507
|
-
await deletePlaythroughData(storyData.ifid, playthroughId);
|
|
508
|
-
const known = await populateKnownSaves(storyData.ifid);
|
|
509
|
-
useStoryStore.setState((state) => {
|
|
510
|
-
state.knownSaves = known;
|
|
511
|
-
});
|
|
475
|
+
deletePlaythrough(playthroughId: string): Promise<void> {
|
|
476
|
+
return useStoryStore.getState().deletePlaythrough(playthroughId);
|
|
512
477
|
},
|
|
513
478
|
|
|
514
479
|
get backend() {
|
package/src/story-init.ts
CHANGED
package/src/story-variables.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Passage } from './parser';
|
|
2
|
-
import { tokenize } from './markup/tokenizer';
|
|
2
|
+
import { tokenize, type Token } from './markup/tokenizer';
|
|
3
|
+
import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
4
|
+
import { errorMessage } from './utils/error-message';
|
|
5
|
+
import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
|
|
6
|
+
import { createNamespace, RESERVED_NAME } from './utils/namespace';
|
|
3
7
|
|
|
4
8
|
export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
|
|
5
9
|
|
|
@@ -17,9 +21,8 @@ function declarationRegex(sigil: string): RegExp {
|
|
|
17
21
|
const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
22
|
return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const INTERP_START_RE = /^[$_@%]\w/;
|
|
24
|
+
/** A `$name` reference with its dot path, at a `$` the lexer found. */
|
|
25
|
+
const VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
|
|
23
26
|
/** Quoted first argument of an input macro naming a story variable. */
|
|
24
27
|
const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
|
|
25
28
|
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
@@ -77,12 +80,17 @@ export function parseStoryVariables(
|
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
const [, name, expr] = match as [string, string, string];
|
|
83
|
+
if (name === RESERVED_NAME) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
80
88
|
let value: unknown;
|
|
81
89
|
try {
|
|
82
90
|
value = new Function('return (' + expr + ')')();
|
|
83
91
|
} catch (err) {
|
|
84
92
|
throw new Error(
|
|
85
|
-
`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err
|
|
93
|
+
`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${errorMessage(err)}`,
|
|
86
94
|
);
|
|
87
95
|
}
|
|
88
96
|
|
|
@@ -90,9 +98,7 @@ export function parseStoryVariables(
|
|
|
90
98
|
try {
|
|
91
99
|
fieldSchema = inferSchema(value);
|
|
92
100
|
} catch (err) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
`${passageName}: ${err instanceof Error ? err.message : err}`,
|
|
95
|
-
);
|
|
101
|
+
throw new Error(`${passageName}: ${errorMessage(err)}`);
|
|
96
102
|
}
|
|
97
103
|
schema.set(name, { ...fieldSchema, name, default: value });
|
|
98
104
|
}
|
|
@@ -128,6 +134,10 @@ function validateRef(
|
|
|
128
134
|
const parts = ref.split('.');
|
|
129
135
|
const rootName = parts[0]!;
|
|
130
136
|
|
|
137
|
+
if (rootName === RESERVED_NAME) {
|
|
138
|
+
return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
// Skip for-loop locals
|
|
132
142
|
if (forLocals.has(rootName)) return null;
|
|
133
143
|
|
|
@@ -187,107 +197,81 @@ const BUILTIN_STORE_VAR_MACROS: readonly string[] = [
|
|
|
187
197
|
|
|
188
198
|
type RefCallback = (ref: string) => void;
|
|
189
199
|
|
|
190
|
-
|
|
191
|
-
function scanRefs(segment: string, onRef: RefCallback): void {
|
|
192
|
-
for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]!);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Index of the quote closing the string opened at `start` (or the end). */
|
|
196
|
-
function findClosingQuote(code: string, start: number): number {
|
|
197
|
-
const quote = code[start];
|
|
198
|
-
let i = start + 1;
|
|
199
|
-
while (i < code.length && code[i] !== quote) {
|
|
200
|
-
i += code[i] === '\\' ? 2 : 1;
|
|
201
|
-
}
|
|
202
|
-
return Math.min(i, code.length);
|
|
203
|
-
}
|
|
200
|
+
const NO_STORE_VAR_MACROS: ReadonlySet<string> = new Set();
|
|
204
201
|
|
|
205
202
|
/**
|
|
206
|
-
* Scan
|
|
207
|
-
*
|
|
208
|
-
* resolve at runtime. A bare `$word` in literal text is not a reference.
|
|
203
|
+
* Scan the value of a code attribute (`onclick`, see isCodeAttribute) for
|
|
204
|
+
* the `{$…}` references resolved in it; its other braces are code.
|
|
209
205
|
*/
|
|
210
|
-
function
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const next = INTERP_START_RE.test(text.slice(i + 1, i + 3))
|
|
214
|
-
? scanCode(text, i + 1, onRef, true)
|
|
215
|
-
: i + 1;
|
|
216
|
-
i = text.indexOf('{', next);
|
|
206
|
+
function scanSigilReferences(value: string, onRef: RefCallback): void {
|
|
207
|
+
for (const part of splitSigilTemplate(value)) {
|
|
208
|
+
if ('expr' in part) scanCode(part.expr, onRef);
|
|
217
209
|
}
|
|
218
210
|
}
|
|
219
211
|
|
|
220
212
|
/**
|
|
221
|
-
* Scan
|
|
222
|
-
*
|
|
223
|
-
*
|
|
213
|
+
* Scan literal text (string contents, HTML attribute values) for the markup
|
|
214
|
+
* that labels and HTML attributes evaluate at runtime: `{$…}` displays,
|
|
215
|
+
* expressions and macros, read as in passage text. A bare `$word` in
|
|
216
|
+
* literal text is not a reference.
|
|
224
217
|
*/
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
} else if (ch === '`') {
|
|
233
|
-
scanInterpolations(code.slice(textStart, i), onRef);
|
|
234
|
-
return i + 1;
|
|
235
|
-
} else if (ch === '$' && code[i + 1] === '{') {
|
|
236
|
-
scanInterpolations(code.slice(textStart, i), onRef);
|
|
237
|
-
i = textStart = scanCode(code, i + 2, onRef, true);
|
|
238
|
-
} else {
|
|
239
|
-
i++;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
scanInterpolations(code.slice(textStart), onRef);
|
|
243
|
-
return code.length;
|
|
218
|
+
function scanInterpolations(
|
|
219
|
+
text: string,
|
|
220
|
+
onRef: RefCallback,
|
|
221
|
+
storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
|
|
222
|
+
): void {
|
|
223
|
+
if (!text.includes('{')) return;
|
|
224
|
+
collectTokenRefs(text, tokenize(text, { text: true }), storeVarMacros, onRef);
|
|
244
225
|
}
|
|
245
226
|
|
|
246
227
|
/**
|
|
247
|
-
* Report `$var` references in JavaScript code
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
228
|
+
* Report `$var` references in JavaScript code, lexed as the expression
|
|
229
|
+
* engine lexes it (`lexJs`): references in code, but none inside string or
|
|
230
|
+
* regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
|
|
231
|
+
* The text of string and template literals is scanned for the markup that
|
|
232
|
+
* labels evaluate (`{$…}`); the `${…}` parts of template literals are code.
|
|
252
233
|
*/
|
|
253
234
|
function scanCode(
|
|
254
235
|
code: string,
|
|
255
|
-
start: number,
|
|
256
236
|
onRef: RefCallback,
|
|
257
|
-
|
|
258
|
-
):
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
237
|
+
goal: JsGoal = 'expression',
|
|
238
|
+
): void {
|
|
239
|
+
/** Open template literals: their text so far, null in an interpolation. */
|
|
240
|
+
const templates: { nesting: number; text: string | null }[] = [];
|
|
241
|
+
lexJs(
|
|
242
|
+
code,
|
|
243
|
+
{
|
|
244
|
+
variable(sigil, _name, index) {
|
|
245
|
+
if (sigil !== '$') return;
|
|
246
|
+
VAR_PATH_RE.lastIndex = index;
|
|
247
|
+
onRef(VAR_PATH_RE.exec(code)![1]!);
|
|
248
|
+
},
|
|
249
|
+
literal(text, _index, nesting) {
|
|
250
|
+
const template = templates[templates.length - 1];
|
|
251
|
+
if (template?.nesting === nesting) {
|
|
252
|
+
if (template.text === null) {
|
|
253
|
+
// The `}` ending an interpolation: back to the template's text
|
|
254
|
+
template.text = '';
|
|
255
|
+
} else if (text === '`' || text === '${') {
|
|
256
|
+
scanInterpolations(template.text, onRef);
|
|
257
|
+
if (text === '`') templates.pop();
|
|
258
|
+
else template.text = null;
|
|
259
|
+
} else {
|
|
260
|
+
template.text += text;
|
|
261
|
+
}
|
|
262
|
+
} else if (text === '`') {
|
|
263
|
+
templates.push({ nesting, text: '' });
|
|
264
|
+
} else if (text[0] === '"' || text[0] === "'") {
|
|
265
|
+
const { closed } = scanStringLiteral(text, 0);
|
|
266
|
+
scanInterpolations(text.slice(1, closed ? -1 : undefined), onRef);
|
|
267
|
+
}
|
|
268
|
+
// Regex literals and comments hold no references
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
goal,
|
|
272
|
+
);
|
|
273
|
+
// An unterminated template literal's text
|
|
274
|
+
for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
|
|
291
275
|
}
|
|
292
276
|
|
|
293
277
|
/**
|
|
@@ -301,19 +285,29 @@ function collectPassageRefs(
|
|
|
301
285
|
storeVarMacros: ReadonlySet<string>,
|
|
302
286
|
onRef: RefCallback,
|
|
303
287
|
): void {
|
|
304
|
-
|
|
288
|
+
collectTokenRefs(content, tokenize(content), storeVarMacros, onRef);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Report the `$var` references in the tokens of `content`. */
|
|
292
|
+
function collectTokenRefs(
|
|
293
|
+
content: string,
|
|
294
|
+
tokens: Token[],
|
|
295
|
+
storeVarMacros: ReadonlySet<string>,
|
|
296
|
+
onRef: RefCallback,
|
|
297
|
+
): void {
|
|
305
298
|
for (let t = 0; t < tokens.length; t++) {
|
|
306
299
|
const token = tokens[t]!;
|
|
307
300
|
if (token.type === 'variable') {
|
|
308
301
|
if (token.scope === 'variable' && token.name) onRef(token.name);
|
|
309
302
|
} else if (token.type === 'expression') {
|
|
310
|
-
scanCode(token.expression,
|
|
303
|
+
scanCode(token.expression, onRef);
|
|
311
304
|
} else if (token.type === 'html') {
|
|
312
|
-
for (const value of Object.
|
|
313
|
-
|
|
305
|
+
for (const [name, value] of Object.entries(token.attributes)) {
|
|
306
|
+
if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
|
|
307
|
+
else scanInterpolations(value, onRef, storeVarMacros);
|
|
314
308
|
}
|
|
315
309
|
} else if (token.type === 'macro' && !token.isClose) {
|
|
316
|
-
scanCode(token.rawArgs,
|
|
310
|
+
scanCode(token.rawArgs, onRef);
|
|
317
311
|
|
|
318
312
|
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
319
313
|
const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
|
|
@@ -331,7 +325,8 @@ function collectPassageRefs(
|
|
|
331
325
|
close++;
|
|
332
326
|
}
|
|
333
327
|
if (close < tokens.length) {
|
|
334
|
-
|
|
328
|
+
const body = content.slice(token.end, tokens[close]!.start);
|
|
329
|
+
scanCode(body, onRef, 'statements');
|
|
335
330
|
t = close;
|
|
336
331
|
}
|
|
337
332
|
}
|
|
@@ -374,12 +369,13 @@ export function validatePassages(
|
|
|
374
369
|
}
|
|
375
370
|
|
|
376
371
|
/**
|
|
377
|
-
* Extract default values from the schema as a
|
|
372
|
+
* Extract default values from the schema as a record without a prototype,
|
|
373
|
+
* like the namespaces it initializes (see utils/namespace.ts).
|
|
378
374
|
*/
|
|
379
375
|
export function extractDefaults(
|
|
380
376
|
schema: Map<string, VariableSchema>,
|
|
381
377
|
): Record<string, unknown> {
|
|
382
|
-
const defaults
|
|
378
|
+
const defaults = createNamespace();
|
|
383
379
|
for (const [name, varSchema] of schema) {
|
|
384
380
|
defaults[name] = varSchema.default;
|
|
385
381
|
}
|
package/src/triggers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluate } from './expression';
|
|
2
|
-
import { executeMutation,
|
|
2
|
+
import { executeMutation, readState } from './execute-mutation';
|
|
3
3
|
import { useStoryStore } from './store';
|
|
4
4
|
|
|
5
5
|
export interface WatchOptions {
|
|
@@ -19,7 +19,6 @@ export interface QueuedDialog {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface Trigger {
|
|
22
|
-
id: number;
|
|
23
22
|
name?: string;
|
|
24
23
|
condition: string;
|
|
25
24
|
callback?: () => void;
|
|
@@ -30,7 +29,6 @@ interface Trigger {
|
|
|
30
29
|
macroKey?: string;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
let nextId = 0;
|
|
34
32
|
let triggers: Trigger[] = [];
|
|
35
33
|
let checking = false;
|
|
36
34
|
|
|
@@ -46,8 +44,9 @@ interface DialogHostCallbacks {
|
|
|
46
44
|
}
|
|
47
45
|
let dialogHostCallbacks: DialogHostCallbacks | null = null;
|
|
48
46
|
|
|
47
|
+
/** A condition's value in program order (see readState). */
|
|
49
48
|
function evalCondition(condition: string): boolean {
|
|
50
|
-
const state =
|
|
49
|
+
const state = readState();
|
|
51
50
|
try {
|
|
52
51
|
return !!evaluate(
|
|
53
52
|
condition,
|
|
@@ -66,13 +65,11 @@ function registerTrigger(
|
|
|
66
65
|
callbackOrOptions: (() => void) | WatchOptions,
|
|
67
66
|
macroKey?: string,
|
|
68
67
|
): () => void {
|
|
69
|
-
const id = nextId++;
|
|
70
68
|
const isCallback = typeof callbackOrOptions === 'function';
|
|
71
69
|
const options = isCallback ? undefined : callbackOrOptions;
|
|
72
70
|
const callback = isCallback ? callbackOrOptions : undefined;
|
|
73
71
|
|
|
74
72
|
const trigger: Trigger = {
|
|
75
|
-
id,
|
|
76
73
|
name: options?.name,
|
|
77
74
|
condition,
|
|
78
75
|
callback,
|
|
@@ -85,8 +82,10 @@ function registerTrigger(
|
|
|
85
82
|
triggers.push(trigger);
|
|
86
83
|
triggers.sort((a, b) => b.priority - a.priority);
|
|
87
84
|
|
|
85
|
+
// By identity: an unsubscribe kept across a restart (resetTriggers) must
|
|
86
|
+
// not remove a watcher added since
|
|
88
87
|
return () => {
|
|
89
|
-
triggers = triggers.filter((t) => t
|
|
88
|
+
triggers = triggers.filter((t) => t !== trigger);
|
|
90
89
|
};
|
|
91
90
|
}
|
|
92
91
|
|
|
@@ -150,9 +149,9 @@ function fireTrigger(trigger: Trigger): void {
|
|
|
150
149
|
|
|
151
150
|
if (options.goto) {
|
|
152
151
|
// A watcher fired by a Story.set in running code navigates after that
|
|
153
|
-
// code's writes so far, so they are part of the moment it leaves
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
// code's writes so far, so they are part of the moment it leaves (the
|
|
153
|
+
// store commits them first).
|
|
154
|
+
useStoryStore.getState().navigate(options.goto);
|
|
156
155
|
}
|
|
157
156
|
}
|
|
158
157
|
|
|
@@ -176,7 +175,7 @@ function runCheckLoop(): void {
|
|
|
176
175
|
anyFired = true;
|
|
177
176
|
|
|
178
177
|
if (trigger.options?.once) {
|
|
179
|
-
triggers = triggers.filter((t) => t
|
|
178
|
+
triggers = triggers.filter((t) => t !== trigger);
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
fireTrigger(trigger);
|
|
@@ -266,7 +265,6 @@ export function reinitTriggerState(): void {
|
|
|
266
265
|
export function resetTriggers(): void {
|
|
267
266
|
triggers = [];
|
|
268
267
|
dialogQueue = [];
|
|
269
|
-
nextId = 0;
|
|
270
268
|
dialogHostCallbacks?.closeAll();
|
|
271
269
|
}
|
|
272
270
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passage counters: the visit and render counts of the story state, keyed
|
|
3
|
+
* by passage name.
|
|
4
|
+
*
|
|
5
|
+
* A passage may have any name, including `constructor`, `toString` or
|
|
6
|
+
* `__proto__`, so the counters only ever read their own entries: a passage
|
|
7
|
+
* that was never counted counts 0, never an Object.prototype member. The
|
|
8
|
+
* store keeps them without a prototype, so writing the count of a passage
|
|
9
|
+
* named `__proto__` stores an entry instead of replacing the prototype.
|
|
10
|
+
* Counters from outside the store (a loaded save) go through
|
|
11
|
+
* createCounts(), which keeps only their own entries that are counts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type Counts = Record<string, number>;
|
|
15
|
+
|
|
16
|
+
const isCount = (value: unknown): value is number =>
|
|
17
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
18
|
+
|
|
19
|
+
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
20
|
+
export function countOf(
|
|
21
|
+
counts: object | null | undefined,
|
|
22
|
+
name: string,
|
|
23
|
+
): number {
|
|
24
|
+
if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
|
|
25
|
+
const value = (counts as Record<string, unknown>)[name];
|
|
26
|
+
return isCount(value) ? value : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Counters with no prototype holding the own entries of `source` that are
|
|
31
|
+
* counts, with `name` (if given) counted once more. Immer copies the
|
|
32
|
+
* counters on every write anyway, so counting into a fresh copy costs no
|
|
33
|
+
* more than counting in place.
|
|
34
|
+
*/
|
|
35
|
+
export function createCounts(source?: object | null, name?: string): Counts {
|
|
36
|
+
const counts = Object.create(null) as Counts;
|
|
37
|
+
if (source) {
|
|
38
|
+
for (const key of Object.keys(source)) {
|
|
39
|
+
const value = (source as Record<string, unknown>)[key];
|
|
40
|
+
if (isCount(value)) counts[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (name !== undefined) counts[name] = countOf(source, name) + 1;
|
|
44
|
+
return counts;
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message to show for a caught value. Anything can be thrown, not only
|
|
3
|
+
* Errors: an Error gives its message, a string is its own message, and any
|
|
4
|
+
* other value gives `fallback` when one is passed, else its string form
|
|
5
|
+
* (never `undefined`). An empty message also gives `fallback`, or for an
|
|
6
|
+
* Error its string form (e.g. "Error").
|
|
7
|
+
*/
|
|
8
|
+
export function errorMessage(err: unknown, fallback?: string): string {
|
|
9
|
+
if (err instanceof Error) return err.message || fallback || String(err);
|
|
10
|
+
if (typeof err === 'string' && err !== '') return err;
|
|
11
|
+
return fallback ?? String(err);
|
|
12
|
+
}
|