@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.
Files changed (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. 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
- let prevVars = { ...useStoryStore.getState().variables };
89
- let prevTrans = { ...useStoryStore.getState().transient };
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 = { ...state.variables };
119
- prevTrans = { ...state.transient };
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
- if (name.startsWith('%')) return { isTransient: true, key: name.slice(1) };
247
- if (name.startsWith('$')) return { isTransient: false, key: name.slice(1) };
248
- return { isTransient: false, key: name };
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 code's writes so
325
- // far before they record, replace or save state, and the code goes on
326
- // from the state they leave (see runWithCommittedMutations).
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
- runWithCommittedMutations(() =>
329
- useStoryStore.getState().navigate(passageName),
330
- );
313
+ useStoryStore.getState().navigate(passageName);
331
314
  },
332
315
 
333
316
  back(): void {
334
- runWithCommittedMutations(() => useStoryStore.getState().goBack());
317
+ useStoryStore.getState().goBack();
335
318
  },
336
319
 
337
320
  forward(): void {
338
- runWithCommittedMutations(() => useStoryStore.getState().goForward());
321
+ useStoryStore.getState().goForward();
339
322
  },
340
323
 
341
324
  restart(): void {
342
- runWithCommittedMutations(() => useStoryStore.getState().restart());
325
+ useStoryStore.getState().restart();
343
326
  },
344
327
 
345
328
  save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
346
- return runWithCommittedMutations(() =>
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[name ?? state.currentPassage] ?? 0;
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[n] ?? 0) > 0);
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[n] ?? 0) > 0);
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[name ?? state.currentPassage] ?? 0;
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[n] ?? 0) > 0);
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[n] ?? 0) > 0);
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
- async clearGameData(): Promise<void> {
487
- const ifid = useStoryStore.getState().storyData?.ifid;
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
- async clearAllData(): Promise<void> {
497
- await _clearAllData();
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
- async deletePlaythrough(playthroughId: string): Promise<void> {
505
- const { storyData } = useStoryStore.getState();
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
@@ -47,7 +47,7 @@ export function executeStoryInit() {
47
47
  document.body.appendChild(container);
48
48
  storyInitContainer = container;
49
49
  render(
50
- h(() => renderNodes(ast) as any, null),
50
+ h(() => renderNodes(ast), null),
51
51
  container,
52
52
  );
53
53
 
@@ -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
- const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
21
- /** `{` followed by a sigil starts an interpolation block inside literal text. */
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 instanceof Error ? err.message : 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
- /** Report every `$var.path` in a code segment free of strings/comments. */
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 literal text (string contents, HTML attribute values) for `{$…}`
207
- * interpolation blocks, which interpolating macros and HTML attributes
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 scanInterpolations(text: string, onRef: RefCallback): void {
211
- let i = text.indexOf('{');
212
- while (i !== -1) {
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 a template literal starting just after its opening backtick: literal
222
- * parts are text, `${…}` parts are code. Returns the index just past the
223
- * closing backtick.
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 scanTemplate(code: string, start: number, onRef: RefCallback): number {
226
- let i = start;
227
- let textStart = start;
228
- while (i < code.length) {
229
- const ch = code[i];
230
- if (ch === '\\') {
231
- i += 2;
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. Like the string-aware
248
- * expression transformer, sigils inside string literals are left alone while
249
- * template-literal `${…}` parts are code; comments are skipped too. When
250
- * `nested`, stops at the `}` closing the enclosing block and returns the index
251
- * just past it.
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
- nested = false,
258
- ): number {
259
- let i = start;
260
- let segStart = start;
261
- let depth = 0;
262
- while (i < code.length) {
263
- const ch = code[i];
264
- const next = code[i + 1];
265
- const isComment = ch === '/' && (next === '/' || next === '*');
266
- if (ch === '"' || ch === "'" || ch === '`' || isComment) {
267
- scanRefs(code.slice(segStart, i), onRef);
268
- if (ch === '`') {
269
- i = scanTemplate(code, i + 1, onRef);
270
- } else if (isComment) {
271
- const close = code.indexOf(next === '/' ? '\n' : '*/', i + 2);
272
- i = close === -1 ? code.length : next === '/' ? close : close + 2;
273
- } else {
274
- const close = findClosingQuote(code, i);
275
- scanInterpolations(code.slice(i + 1, close), onRef);
276
- i = Math.min(close + 1, code.length);
277
- }
278
- segStart = i;
279
- continue;
280
- }
281
- if (nested && ch === '{') {
282
- depth++;
283
- } else if (nested && ch === '}' && depth-- === 0) {
284
- scanRefs(code.slice(segStart, i), onRef);
285
- return i + 1;
286
- }
287
- i++;
288
- }
289
- scanRefs(code.slice(segStart), onRef);
290
- return code.length;
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
- const tokens = tokenize(content);
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, 0, onRef);
303
+ scanCode(token.expression, onRef);
311
304
  } else if (token.type === 'html') {
312
- for (const value of Object.values(token.attributes)) {
313
- scanInterpolations(value, onRef);
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, 0, onRef);
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
- scanCode(content.slice(token.end, tokens[close]!.start), 0, onRef);
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 plain object.
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: Record<string, unknown> = {};
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, runWithCommittedMutations } from './execute-mutation';
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 = useStoryStore.getState();
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.id !== id);
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
- const passage = options.goto;
155
- runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
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.id !== trigger.id);
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
+ }