@rohal12/spindle 0.51.3 → 0.52.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.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  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 +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
@@ -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,
@@ -22,6 +22,7 @@ import {
22
22
  } from '../../saves/save-manager';
23
23
  import { DialogCloseContext } from '../PassageDialog';
24
24
  import { defineMacro } from '../../define-macro';
25
+ import { errorMessage } from '../../utils/error-message';
25
26
 
26
27
  function relativeTime(iso: string): string {
27
28
  const diff = Date.now() - new Date(iso).getTime();
@@ -84,15 +85,25 @@ export function SaveManagerContent() {
84
85
  }
85
86
  }, [renamingId]);
86
87
 
88
+ // Actions are async and may finish after the manager closed: once
89
+ // unmounted, no status is shown and no timer is left behind.
90
+ const mounted = useRef(true);
87
91
  const statusTimer = useRef<number>();
92
+ const closeTimer = useRef<number>();
88
93
  const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
94
+ if (!mounted.current) return;
89
95
  clearTimeout(statusTimer.current);
90
96
  setStatus({ text, type });
91
97
  statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
92
98
  };
93
99
 
94
100
  useEffect(() => {
95
- return () => clearTimeout(statusTimer.current);
101
+ mounted.current = true;
102
+ return () => {
103
+ mounted.current = false;
104
+ clearTimeout(statusTimer.current);
105
+ clearTimeout(closeTimer.current);
106
+ };
96
107
  }, []);
97
108
 
98
109
  const toggleCollapse = (id: string) => {
@@ -106,11 +117,16 @@ export function SaveManagerContent() {
106
117
 
107
118
  // Dialog saves are not slot saves, so the save hooks get no slot.
108
119
  const handleNewSave = async () => {
109
- if (!ifid || !playthroughId) return;
120
+ if (!ifid) return;
121
+ // The playthrough current at the click, which a restart may have
122
+ // switched since this render
123
+ const playthrough = resolvePlaythroughId();
110
124
  try {
111
- await saveWithHooks(undefined, undefined, beginSave, (payload) =>
112
- createSave(ifid, playthroughId, payload),
113
- );
125
+ await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
126
+ const ptId = await playthrough;
127
+ if (!ptId) throw new Error('No playthrough');
128
+ return createSave(ifid, ptId, payload);
129
+ });
114
130
  showStatus('Save created');
115
131
  await refresh();
116
132
  } catch {
@@ -119,9 +135,12 @@ export function SaveManagerContent() {
119
135
  };
120
136
 
121
137
  const handleOverwrite = async (saveId: string) => {
138
+ const playthrough = resolvePlaythroughId();
122
139
  try {
123
140
  await saveWithHooks(undefined, undefined, beginSave, async (payload) => {
124
- if (!(await overwriteSave(saveId, payload))) {
141
+ // Only the current playthrough's saves offer "Save Here"
142
+ const ptId = (await playthrough) || undefined;
143
+ if (!(await overwriteSave(saveId, payload, undefined, ptId))) {
125
144
  throw new Error('Save not found');
126
145
  }
127
146
  });
@@ -134,10 +153,19 @@ export function SaveManagerContent() {
134
153
 
135
154
  const handleLoad = async (save: SaveRecord) => {
136
155
  try {
137
- // Stored records hold serialized variables; the store expects live ones
138
- loadFromPayload(deserializePayload(save.payload));
156
+ // Stored records hold serialized variables; the store expects live
157
+ // ones. The game moves to the save's playthrough.
158
+ loadFromPayload(
159
+ deserializePayload(save.payload),
160
+ undefined,
161
+ save.meta.playthroughId,
162
+ );
139
163
  showStatus('Game loaded');
140
- if (closeDialog) setTimeout(closeDialog, 500);
164
+ if (closeDialog) {
165
+ closeTimer.current = window.setTimeout(closeDialog, 500);
166
+ }
167
+ // A playthrough deleted since the list was read is recorded again
168
+ await refresh();
141
169
  } catch {
142
170
  showStatus('Failed to load save', 'error');
143
171
  }
@@ -221,10 +249,7 @@ export function SaveManagerContent() {
221
249
  showStatus('Save imported');
222
250
  await refresh();
223
251
  } catch (err) {
224
- showStatus(
225
- err instanceof Error ? err.message : 'Failed to import save',
226
- 'error',
227
- );
252
+ showStatus(errorMessage(err, 'Failed to import save'), 'error');
228
253
  }
229
254
  };
230
255
 
@@ -6,4 +6,5 @@ defineMacro({
6
6
  render({ children = [] }, ctx) {
7
7
  return ctx.wrap(ctx.renderNodes(children));
8
8
  },
9
+ text: ({ children = [] }, ctx) => ctx.renderText(children),
9
10
  });
@@ -16,4 +16,5 @@ defineMacro({
16
16
  </span>
17
17
  );
18
18
  },
19
+ text: () => useStoryStore.getState().storyData?.name || '',
19
20
  });
@@ -47,4 +47,17 @@ defineMacro({
47
47
 
48
48
  return null;
49
49
  },
50
+ text({ rawArgs, branches = [] }, ctx) {
51
+ const switchValue = ctx.evaluate(rawArgs);
52
+ let defaultBranch: (typeof branches)[number] | null = null;
53
+ for (let i = 1; i < branches.length; i++) {
54
+ const branch = branches[i]!;
55
+ if (branch.rawArgs === '') {
56
+ defaultBranch = branch;
57
+ } else if (switchValue === ctx.evaluate(branch.rawArgs)) {
58
+ return ctx.renderText(branch.children);
59
+ }
60
+ }
61
+ return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
62
+ },
50
63
  });
@@ -1,31 +1,51 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
+ import { MacroError } from './MacroError';
3
4
 
4
5
  defineMacro({
5
6
  name: 'unset',
6
7
  render({ rawArgs }, ctx) {
7
8
  const ran = ctx.hooks.useRef(false);
9
+ const error = ctx.hooks.useRef<unknown>(null);
8
10
 
9
11
  if (!ran.current) {
10
12
  ran.current = true;
11
13
  const state = useStoryStore.getState();
12
14
  const name = rawArgs.trim();
13
15
 
14
- if (name.startsWith('$')) {
15
- state.deleteVariable(name.slice(1));
16
- } else if (name.startsWith('_')) {
17
- state.deleteTemporary(name.slice(1));
18
- } else if (name.startsWith('%')) {
19
- state.deleteTransient(name.slice(1));
20
- } else if (name.startsWith('@')) {
21
- ctx.update(name.slice(1), undefined);
22
- } else {
16
+ try {
17
+ if (name.startsWith('$')) {
18
+ state.deleteVariable(name.slice(1));
19
+ } else if (name.startsWith('_')) {
20
+ state.deleteTemporary(name.slice(1));
21
+ } else if (name.startsWith('%')) {
22
+ state.deleteTransient(name.slice(1));
23
+ } else if (name.startsWith('@')) {
24
+ // Throws outside a locals scope ({for}, widget, {link}, {button})
25
+ ctx.update(name.slice(1), undefined);
26
+ } else {
27
+ throw new Error(
28
+ `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
29
+ );
30
+ }
31
+ } catch (err) {
32
+ error.current = err;
23
33
  console.error(
24
- `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
34
+ `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
35
+ err,
25
36
  );
26
37
  }
27
38
  }
28
39
 
40
+ if (error.current) {
41
+ return (
42
+ <MacroError
43
+ macro="unset"
44
+ error={error.current}
45
+ />
46
+ );
47
+ }
48
+
29
49
  return null;
30
50
  },
31
51
  });
@@ -2,6 +2,14 @@ import { useStoryStore } from '../../store';
2
2
  import { useContext } from 'preact/hooks';
3
3
  import { LocalsValuesContext } from '../../markup/render';
4
4
  import { useInterpolate } from '../../hooks/use-interpolate';
5
+ import { RESERVED_NAME, ownValue } from '../../utils/namespace';
6
+
7
+ const SIGILS = {
8
+ variable: '$',
9
+ temporary: '_',
10
+ local: '@',
11
+ transient: '%',
12
+ } as const;
5
13
 
6
14
  interface VarDisplayProps {
7
15
  name: string;
@@ -17,19 +25,28 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
17
25
  const localsValues = useContext(LocalsValuesContext);
18
26
  const parts = name.split('.');
19
27
  const root = parts[0]!;
28
+ // Own entries only, as the expression engine reads them
20
29
  const storeValue = useStoryStore((s) =>
21
30
  scope === 'variable'
22
- ? s.variables[root]
31
+ ? ownValue(s.variables, root)
23
32
  : scope === 'temporary'
24
- ? s.temporary[root]
33
+ ? ownValue(s.temporary, root)
25
34
  : scope === 'transient'
26
- ? s.transient[root]
35
+ ? ownValue(s.transient, root)
27
36
  : undefined,
28
37
  );
29
38
 
39
+ if (root === RESERVED_NAME) {
40
+ return (
41
+ <span class="error">
42
+ {`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
43
+ </span>
44
+ );
45
+ }
46
+
30
47
  let value: unknown;
31
48
  if (scope === 'local') {
32
- value = root in localsValues ? localsValues[root] : undefined;
49
+ value = ownValue(localsValues, root);
33
50
  } else {
34
51
  value = storeValue;
35
52
  }
@@ -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
- defineMacro({
6
- name: 'watch',
7
- render(props, ctx) {
8
- const { hooks } = ctx;
6
+ const WORD_RE = /\w+/y;
7
+ const DIGITS_RE = /\d+/y;
9
8
 
10
- // Parse: condition string + keyword options
11
- const raw = props.rawArgs.trim();
12
- // First quoted string is the condition
13
- const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
14
- if (!condMatch) return null;
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
- const condition = condMatch[2]!;
17
- const rest = raw.slice(condMatch[0].length);
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
- const options: WatchOptions = {};
20
- // Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
21
- const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
22
- let m: RegExpExecArray | null;
23
- while ((m = kwRe.exec(rest)) !== null) {
24
- const key = m[1] ?? m[5];
25
- const val = m[2] ?? m[3] ?? m[4];
26
- if (!key) continue;
27
- switch (key) {
28
- case 'goto':
29
- options.goto = val;
30
- break;
31
- case 'dialog':
32
- options.dialog = val;
33
- break;
34
- case 'run':
35
- options.run = val;
36
- break;
37
- case 'name':
38
- options.name = val;
39
- break;
40
- case 'priority':
41
- options.priority = val ? Number(val) : 0;
42
- break;
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 raw = props.rawArgs.trim();
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) {
@@ -2,11 +2,14 @@ import { registerWidget } from '../../widgets/widget-registry';
2
2
  import { astContainsChildren } from '../../widgets/ast-scanner';
3
3
  import { registerBlockMacro } from '../../markup/ast';
4
4
  import { defineMacro } from '../../define-macro';
5
+ import { checkVariableName } from '../../utils/namespace';
6
+ import { MacroError } from './MacroError';
5
7
 
6
8
  function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
7
9
  const tokens = rawArgs.trim().split(/\s+/);
8
10
  const name = tokens[0]!.replace(/["']/g, '');
9
11
  const params = tokens.slice(1).filter((t) => t.startsWith('@'));
12
+ for (const param of params) checkVariableName(param.slice(1), param);
10
13
  return { name, params };
11
14
  }
12
15
 
@@ -14,12 +17,20 @@ defineMacro({
14
17
  name: 'widget',
15
18
  block: true,
16
19
  render({ rawArgs, children = [] }, ctx) {
17
- const { name, params } = parseWidgetDef(rawArgs);
20
+ let parsed: ReturnType<typeof parseWidgetDef> | undefined;
21
+ let error: unknown;
22
+ try {
23
+ parsed = parseWidgetDef(rawArgs);
24
+ } catch (err) {
25
+ error = err;
26
+ }
27
+ const { name, params } = parsed ?? { name: '', params: [] };
18
28
 
19
29
  const childrenKey = JSON.stringify(children);
20
30
  const paramsKey = params.join(',');
21
31
 
22
32
  ctx.hooks.useLayoutEffect(() => {
33
+ if (!parsed) return;
23
34
  // Widgets whose body renders {@children} take a closing tag; register
24
35
  // them as block macros so passages parsed later nest their content.
25
36
  const isBlock = astContainsChildren(children);
@@ -27,6 +38,14 @@ defineMacro({
27
38
  if (isBlock) registerBlockMacro(name);
28
39
  }, [name, childrenKey, paramsKey]);
29
40
 
41
+ if (error) {
42
+ return (
43
+ <MacroError
44
+ macro="widget"
45
+ error={error}
46
+ />
47
+ );
48
+ }
30
49
  return null;
31
50
  },
32
51
  });