@rohal12/spindle 0.1.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 (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. package/src/widgets/widget-registry.ts +15 -0
package/src/store.ts ADDED
@@ -0,0 +1,317 @@
1
+ import { create } from 'zustand';
2
+ import { immer } from 'zustand/middleware/immer';
3
+ import type { StoryData } from './parser';
4
+ import type { SavePayload } from './saves/types';
5
+ import { executeStoryInit } from './story-init';
6
+ import {
7
+ initSaveSystem,
8
+ startNewPlaythrough,
9
+ getCurrentPlaythroughId,
10
+ quickSave,
11
+ loadQuickSave,
12
+ } from './saves/save-manager';
13
+
14
+ export interface HistoryMoment {
15
+ passage: string;
16
+ variables: Record<string, unknown>;
17
+ timestamp: number;
18
+ }
19
+
20
+ export interface StoryState {
21
+ storyData: StoryData | null;
22
+ currentPassage: string;
23
+ variables: Record<string, unknown>;
24
+ variableDefaults: Record<string, unknown>;
25
+ temporary: Record<string, unknown>;
26
+ history: HistoryMoment[];
27
+ historyIndex: number;
28
+ visitCounts: Record<string, number>;
29
+ renderCounts: Record<string, number>;
30
+ saveVersion: number;
31
+ playthroughId: string;
32
+
33
+ init: (
34
+ storyData: StoryData,
35
+ variableDefaults?: Record<string, unknown>,
36
+ ) => void;
37
+ navigate: (passageName: string) => void;
38
+ goBack: () => void;
39
+ goForward: () => void;
40
+ setVariable: (name: string, value: unknown) => void;
41
+ setTemporary: (name: string, value: unknown) => void;
42
+ deleteVariable: (name: string) => void;
43
+ deleteTemporary: (name: string) => void;
44
+ trackRender: (passageName: string) => void;
45
+ restart: () => void;
46
+ save: (slot?: string) => void;
47
+ load: (slot?: string) => void;
48
+ hasSave: (slot?: string) => boolean;
49
+ getSavePayload: () => SavePayload;
50
+ loadFromPayload: (payload: SavePayload) => void;
51
+ }
52
+
53
+ export const useStoryStore = create<StoryState>()(
54
+ immer((set, get) => ({
55
+ storyData: null,
56
+ currentPassage: '',
57
+ variables: {},
58
+ variableDefaults: {},
59
+ temporary: {},
60
+ history: [],
61
+ historyIndex: -1,
62
+ visitCounts: {},
63
+ renderCounts: {},
64
+ saveVersion: 0,
65
+ playthroughId: '',
66
+
67
+ init: (
68
+ storyData: StoryData,
69
+ variableDefaults: Record<string, unknown> = {},
70
+ ) => {
71
+ const startPassage = storyData.passagesById.get(storyData.startNode);
72
+ if (!startPassage) {
73
+ throw new Error(
74
+ `spindle: Start passage (pid=${storyData.startNode}) not found.`,
75
+ );
76
+ }
77
+
78
+ const initialVars = structuredClone(variableDefaults);
79
+
80
+ set((state) => {
81
+ state.storyData = storyData as StoryData;
82
+ state.currentPassage = startPassage.name;
83
+ state.variables = initialVars;
84
+ state.variableDefaults = variableDefaults;
85
+ state.temporary = {};
86
+ state.history = [
87
+ {
88
+ passage: startPassage.name,
89
+ variables: structuredClone(initialVars),
90
+ timestamp: Date.now(),
91
+ },
92
+ ];
93
+ state.historyIndex = 0;
94
+ state.visitCounts = { [startPassage.name]: 1 };
95
+ state.renderCounts = { [startPassage.name]: 1 };
96
+ });
97
+
98
+ // Init save system (fire-and-forget — DB will be ready before user opens dialog)
99
+ const ifid = storyData.ifid;
100
+ initSaveSystem().then(async () => {
101
+ const existingId = await getCurrentPlaythroughId(ifid);
102
+ if (existingId) {
103
+ set((state) => {
104
+ state.playthroughId = existingId;
105
+ });
106
+ } else {
107
+ const newId = await startNewPlaythrough(ifid);
108
+ set((state) => {
109
+ state.playthroughId = newId;
110
+ });
111
+ }
112
+ });
113
+ },
114
+
115
+ navigate: (passageName: string) => {
116
+ const { storyData } = get();
117
+ if (!storyData) return;
118
+
119
+ if (!storyData.passages.has(passageName)) {
120
+ console.error(`spindle: Passage "${passageName}" not found.`);
121
+ return;
122
+ }
123
+
124
+ set((state) => {
125
+ state.temporary = {};
126
+ state.currentPassage = passageName;
127
+
128
+ // Truncate forward history if we navigated back then chose a new path
129
+ state.history = state.history.slice(0, state.historyIndex + 1);
130
+
131
+ state.history.push({
132
+ passage: passageName,
133
+ variables: { ...state.variables },
134
+ timestamp: Date.now(),
135
+ });
136
+ state.historyIndex = state.history.length - 1;
137
+ state.visitCounts[passageName] =
138
+ (state.visitCounts[passageName] ?? 0) + 1;
139
+ state.renderCounts[passageName] =
140
+ (state.renderCounts[passageName] ?? 0) + 1;
141
+ });
142
+ },
143
+
144
+ goBack: () => {
145
+ set((state) => {
146
+ if (state.historyIndex <= 0) return;
147
+ state.historyIndex--;
148
+ const moment = state.history[state.historyIndex];
149
+ state.currentPassage = moment.passage;
150
+ state.variables = { ...moment.variables };
151
+ state.temporary = {};
152
+ });
153
+ },
154
+
155
+ goForward: () => {
156
+ set((state) => {
157
+ if (state.historyIndex >= state.history.length - 1) return;
158
+ state.historyIndex++;
159
+ const moment = state.history[state.historyIndex];
160
+ state.currentPassage = moment.passage;
161
+ state.variables = { ...moment.variables };
162
+ state.temporary = {};
163
+ });
164
+ },
165
+
166
+ setVariable: (name: string, value: unknown) => {
167
+ set((state) => {
168
+ state.variables[name] = value;
169
+ });
170
+ },
171
+
172
+ setTemporary: (name: string, value: unknown) => {
173
+ set((state) => {
174
+ state.temporary[name] = value;
175
+ });
176
+ },
177
+
178
+ deleteVariable: (name: string) => {
179
+ set((state) => {
180
+ delete state.variables[name];
181
+ });
182
+ },
183
+
184
+ deleteTemporary: (name: string) => {
185
+ set((state) => {
186
+ delete state.temporary[name];
187
+ });
188
+ },
189
+
190
+ trackRender: (passageName: string) => {
191
+ set((state) => {
192
+ state.renderCounts[passageName] =
193
+ (state.renderCounts[passageName] ?? 0) + 1;
194
+ });
195
+ },
196
+
197
+ restart: () => {
198
+ const { storyData, variableDefaults } = get();
199
+ if (!storyData) return;
200
+
201
+ const startPassage = storyData.passagesById.get(storyData.startNode);
202
+ if (!startPassage) return;
203
+
204
+ const initialVars = structuredClone(variableDefaults);
205
+
206
+ set((state) => {
207
+ state.currentPassage = startPassage.name;
208
+ state.variables = initialVars;
209
+ state.temporary = {};
210
+ state.history = [
211
+ {
212
+ passage: startPassage.name,
213
+ variables: structuredClone(initialVars),
214
+ timestamp: Date.now(),
215
+ },
216
+ ];
217
+ state.historyIndex = 0;
218
+ state.visitCounts = { [startPassage.name]: 1 };
219
+ state.renderCounts = { [startPassage.name]: 1 };
220
+ });
221
+
222
+ executeStoryInit();
223
+
224
+ // Start a new playthrough on restart
225
+ startNewPlaythrough(storyData.ifid).then((newId) => {
226
+ set((state) => {
227
+ state.playthroughId = newId;
228
+ });
229
+ });
230
+ },
231
+
232
+ save: () => {
233
+ const {
234
+ storyData,
235
+ playthroughId,
236
+ currentPassage,
237
+ variables,
238
+ history,
239
+ historyIndex,
240
+ visitCounts,
241
+ renderCounts,
242
+ } = get();
243
+ if (!storyData) return;
244
+
245
+ const payload: SavePayload = {
246
+ passage: currentPassage,
247
+ variables: structuredClone(variables),
248
+ history: structuredClone(history),
249
+ historyIndex,
250
+ visitCounts: { ...visitCounts },
251
+ renderCounts: { ...renderCounts },
252
+ };
253
+
254
+ quickSave(storyData.ifid, playthroughId, payload).then(() => {
255
+ set((state) => {
256
+ state.saveVersion++;
257
+ });
258
+ });
259
+ },
260
+
261
+ load: () => {
262
+ const { storyData } = get();
263
+ if (!storyData) return;
264
+
265
+ loadQuickSave(storyData.ifid).then((payload) => {
266
+ if (!payload) return;
267
+ set((state) => {
268
+ state.currentPassage = payload.passage;
269
+ state.variables = payload.variables;
270
+ state.history = payload.history;
271
+ state.historyIndex = payload.historyIndex;
272
+ state.visitCounts = payload.visitCounts ?? {};
273
+ state.renderCounts = payload.renderCounts ?? {};
274
+ state.temporary = {};
275
+ });
276
+ });
277
+ },
278
+
279
+ hasSave: () => {
280
+ // Synchronous: return true if a save has been made this session
281
+ const { storyData, saveVersion } = get();
282
+ if (!storyData) return false;
283
+ return saveVersion > 0;
284
+ },
285
+
286
+ getSavePayload: (): SavePayload => {
287
+ const {
288
+ currentPassage,
289
+ variables,
290
+ history,
291
+ historyIndex,
292
+ visitCounts,
293
+ renderCounts,
294
+ } = get();
295
+ return {
296
+ passage: currentPassage,
297
+ variables: structuredClone(variables),
298
+ history: structuredClone(history),
299
+ historyIndex,
300
+ visitCounts: { ...visitCounts },
301
+ renderCounts: { ...renderCounts },
302
+ };
303
+ },
304
+
305
+ loadFromPayload: (payload: SavePayload) => {
306
+ set((state) => {
307
+ state.currentPassage = payload.passage;
308
+ state.variables = payload.variables;
309
+ state.history = payload.history;
310
+ state.historyIndex = payload.historyIndex;
311
+ state.visitCounts = payload.visitCounts ?? {};
312
+ state.renderCounts = payload.renderCounts ?? {};
313
+ state.temporary = {};
314
+ });
315
+ },
316
+ })),
317
+ );
@@ -0,0 +1,129 @@
1
+ import { useStoryStore } from './store';
2
+ import { settings } from './settings';
3
+ import type { SavePayload } from './saves/types';
4
+ import { setTitleGenerator } from './saves/save-manager';
5
+
6
+ export interface StoryAPI {
7
+ get(name: string): unknown;
8
+ set(name: string, value: unknown): void;
9
+ set(vars: Record<string, unknown>): void;
10
+ goto(passageName: string): void;
11
+ back(): void;
12
+ forward(): void;
13
+ restart(): void;
14
+ save(slot?: string): void;
15
+ load(slot?: string): void;
16
+ hasSave(slot?: string): boolean;
17
+ visited(name: string): number;
18
+ hasVisited(name: string): boolean;
19
+ hasVisitedAny(...names: string[]): boolean;
20
+ hasVisitedAll(...names: string[]): boolean;
21
+ rendered(name: string): number;
22
+ hasRendered(name: string): boolean;
23
+ hasRenderedAny(...names: string[]): boolean;
24
+ hasRenderedAll(...names: string[]): boolean;
25
+ readonly title: string;
26
+ readonly settings: typeof settings;
27
+ readonly saves: {
28
+ setTitleGenerator(fn: (payload: SavePayload) => string): void;
29
+ };
30
+ }
31
+
32
+ function createStoryAPI(): StoryAPI {
33
+ return {
34
+ get(name: string): unknown {
35
+ return useStoryStore.getState().variables[name];
36
+ },
37
+
38
+ set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
39
+ const state = useStoryStore.getState();
40
+ if (typeof nameOrVars === 'string') {
41
+ state.setVariable(nameOrVars, value);
42
+ } else {
43
+ for (const [k, v] of Object.entries(nameOrVars)) {
44
+ state.setVariable(k, v);
45
+ }
46
+ }
47
+ },
48
+
49
+ goto(passageName: string): void {
50
+ useStoryStore.getState().navigate(passageName);
51
+ },
52
+
53
+ back(): void {
54
+ useStoryStore.getState().goBack();
55
+ },
56
+
57
+ forward(): void {
58
+ useStoryStore.getState().goForward();
59
+ },
60
+
61
+ restart(): void {
62
+ useStoryStore.getState().restart();
63
+ },
64
+
65
+ save(slot?: string): void {
66
+ useStoryStore.getState().save(slot);
67
+ },
68
+
69
+ load(slot?: string): void {
70
+ useStoryStore.getState().load(slot);
71
+ },
72
+
73
+ hasSave(slot?: string): boolean {
74
+ return useStoryStore.getState().hasSave(slot);
75
+ },
76
+
77
+ visited(name: string): number {
78
+ return useStoryStore.getState().visitCounts[name] ?? 0;
79
+ },
80
+
81
+ hasVisited(name: string): boolean {
82
+ return (useStoryStore.getState().visitCounts[name] ?? 0) > 0;
83
+ },
84
+
85
+ hasVisitedAny(...names: string[]): boolean {
86
+ const { visitCounts } = useStoryStore.getState();
87
+ return names.some((n) => (visitCounts[n] ?? 0) > 0);
88
+ },
89
+
90
+ hasVisitedAll(...names: string[]): boolean {
91
+ const { visitCounts } = useStoryStore.getState();
92
+ return names.every((n) => (visitCounts[n] ?? 0) > 0);
93
+ },
94
+
95
+ rendered(name: string): number {
96
+ return useStoryStore.getState().renderCounts[name] ?? 0;
97
+ },
98
+
99
+ hasRendered(name: string): boolean {
100
+ return (useStoryStore.getState().renderCounts[name] ?? 0) > 0;
101
+ },
102
+
103
+ hasRenderedAny(...names: string[]): boolean {
104
+ const { renderCounts } = useStoryStore.getState();
105
+ return names.some((n) => (renderCounts[n] ?? 0) > 0);
106
+ },
107
+
108
+ hasRenderedAll(...names: string[]): boolean {
109
+ const { renderCounts } = useStoryStore.getState();
110
+ return names.every((n) => (renderCounts[n] ?? 0) > 0);
111
+ },
112
+
113
+ get title(): string {
114
+ return useStoryStore.getState().storyData?.name || '';
115
+ },
116
+
117
+ settings,
118
+
119
+ saves: {
120
+ setTitleGenerator(fn: (payload: SavePayload) => string): void {
121
+ setTitleGenerator(fn);
122
+ },
123
+ },
124
+ };
125
+ }
126
+
127
+ export function installStoryAPI(): void {
128
+ (window as any).Story = createStoryAPI();
129
+ }
@@ -0,0 +1,67 @@
1
+ import { useStoryStore } from './store';
2
+ import { tokenize } from './markup/tokenizer';
3
+ import { buildAST } from './markup/ast';
4
+ import { execute } from './expression';
5
+ import type { ASTNode } from './markup/ast';
6
+ import { setSaveTitlePassage } from './saves/save-manager';
7
+
8
+ /**
9
+ * Walk AST nodes from StoryInit and execute {set} and {do} imperatively
10
+ * (no Preact rendering needed for initialization).
11
+ */
12
+ function walkAndExecute(
13
+ nodes: ASTNode[],
14
+ vars: Record<string, unknown>,
15
+ temps: Record<string, unknown>,
16
+ ) {
17
+ for (const node of nodes) {
18
+ if (node.type !== 'macro') continue;
19
+
20
+ if (node.name === 'set') {
21
+ execute(node.rawArgs, vars, temps);
22
+ } else if (node.name === 'do') {
23
+ const code = node.children
24
+ .map((n) => (n.type === 'text' ? n.value : ''))
25
+ .join('');
26
+ execute(code, vars, temps);
27
+ }
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Execute the StoryInit passage: tokenize, parse, and run all {set}/{do} macros.
33
+ * Called during boot and after restart() to ensure variables are initialized.
34
+ */
35
+ export function executeStoryInit() {
36
+ const state = useStoryStore.getState();
37
+ if (!state.storyData) return;
38
+
39
+ const storyInit = state.storyData.passages.get('StoryInit');
40
+ if (!storyInit) return;
41
+
42
+ const tokens = tokenize(storyInit.content);
43
+ const ast = buildAST(tokens);
44
+
45
+ const vars = { ...state.variables };
46
+ const temps = { ...state.temporary };
47
+
48
+ walkAndExecute(ast, vars, temps);
49
+
50
+ // Apply all changes to the store
51
+ for (const key of Object.keys(vars)) {
52
+ if (vars[key] !== state.variables[key]) {
53
+ state.setVariable(key, vars[key]);
54
+ }
55
+ }
56
+ for (const key of Object.keys(temps)) {
57
+ if (temps[key] !== state.temporary[key]) {
58
+ state.setTemporary(key, temps[key]);
59
+ }
60
+ }
61
+
62
+ // Register SaveTitle passage if it exists
63
+ const saveTitlePassage = state.storyData.passages.get('SaveTitle');
64
+ if (saveTitlePassage) {
65
+ setSaveTitlePassage(saveTitlePassage.content);
66
+ }
67
+ }
@@ -0,0 +1,166 @@
1
+ import type { Passage } from './parser';
2
+
3
+ export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
4
+
5
+ export interface FieldSchema {
6
+ type: VarType;
7
+ fields?: Map<string, FieldSchema>; // only for objects
8
+ }
9
+
10
+ export interface VariableSchema extends FieldSchema {
11
+ name: string;
12
+ default: unknown;
13
+ }
14
+
15
+ const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
16
+ const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
17
+ const FOR_LOCAL_RE = /\{for\s+(\$\w+)(?:\s*,\s*(\$\w+))?\s+of\b/g;
18
+
19
+ function inferSchema(value: unknown): FieldSchema {
20
+ if (Array.isArray(value)) {
21
+ return { type: 'array' };
22
+ }
23
+ if (value !== null && typeof value === 'object') {
24
+ const fields = new Map<string, FieldSchema>();
25
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
26
+ fields.set(key, inferSchema(val));
27
+ }
28
+ return { type: 'object', fields };
29
+ }
30
+ return { type: typeof value as VarType };
31
+ }
32
+
33
+ /**
34
+ * Parse a StoryVariables passage content into a schema map.
35
+ * Each line: `$varName = expression`
36
+ */
37
+ export function parseStoryVariables(
38
+ content: string,
39
+ ): Map<string, VariableSchema> {
40
+ const schema = new Map<string, VariableSchema>();
41
+
42
+ for (const rawLine of content.split('\n')) {
43
+ const line = rawLine.trim();
44
+ if (!line) continue;
45
+
46
+ const match = line.match(DECLARATION_RE);
47
+ if (!match) {
48
+ throw new Error(
49
+ `StoryVariables: Invalid declaration: "${line}". Expected: $name = value`,
50
+ );
51
+ }
52
+
53
+ const [, name, expr] = match;
54
+ let value: unknown;
55
+ try {
56
+ value = new Function('return (' + expr + ')')();
57
+ } catch (err) {
58
+ throw new Error(
59
+ `StoryVariables: Failed to evaluate "$${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
60
+ );
61
+ }
62
+
63
+ const fieldSchema = inferSchema(value);
64
+ schema.set(name, { ...fieldSchema, name, default: value });
65
+ }
66
+
67
+ return schema;
68
+ }
69
+
70
+ /**
71
+ * Extract for-loop local variable names from passage content.
72
+ * `{for $item of ...}` → "item"
73
+ * `{for $index, $item of ...}` → "index", "item"
74
+ */
75
+ function extractForLocals(content: string): Set<string> {
76
+ const locals = new Set<string>();
77
+ let match: RegExpExecArray | null;
78
+ FOR_LOCAL_RE.lastIndex = 0;
79
+ while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
80
+ locals.add(match[1].slice(1)); // strip $
81
+ if (match[2]) locals.add(match[2].slice(1));
82
+ }
83
+ return locals;
84
+ }
85
+
86
+ /**
87
+ * Validate a single variable reference path (e.g. "player.health") against
88
+ * the schema. Returns an error message or null if valid.
89
+ */
90
+ function validateRef(
91
+ ref: string,
92
+ schema: Map<string, VariableSchema>,
93
+ forLocals: Set<string>,
94
+ ): string | null {
95
+ const parts = ref.split('.');
96
+ const rootName = parts[0];
97
+
98
+ // Skip for-loop locals
99
+ if (forLocals.has(rootName)) return null;
100
+
101
+ const rootSchema = schema.get(rootName);
102
+ if (!rootSchema) {
103
+ return `Undeclared variable: $${ref}`;
104
+ }
105
+
106
+ // Walk through field access path
107
+ let current: FieldSchema = rootSchema;
108
+ for (let i = 1; i < parts.length; i++) {
109
+ // Arrays have a .length property
110
+ if (current.type === 'array' && parts[i] === 'length') return null;
111
+
112
+ if (current.type !== 'object' || !current.fields) {
113
+ return `Cannot access field "${parts[i]}" on $${parts.slice(0, i).join('.')} (type: ${current.type})`;
114
+ }
115
+ const fieldSchema = current.fields.get(parts[i]);
116
+ if (!fieldSchema) {
117
+ return `Undeclared field: $${parts.slice(0, i + 1).join('.')}`;
118
+ }
119
+ current = fieldSchema;
120
+ }
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Scan all passages for $var references, check against schema.
127
+ * Returns list of error messages (empty = valid).
128
+ */
129
+ export function validatePassages(
130
+ passages: Map<string, Passage>,
131
+ schema: Map<string, VariableSchema>,
132
+ ): string[] {
133
+ const errors: string[] = [];
134
+
135
+ for (const [name, passage] of passages) {
136
+ // Don't validate the StoryVariables passage itself
137
+ if (name === 'StoryVariables') continue;
138
+
139
+ const forLocals = extractForLocals(passage.content);
140
+
141
+ let match: RegExpExecArray | null;
142
+ VAR_REF_RE.lastIndex = 0;
143
+ while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
144
+ const ref = match[1];
145
+ const error = validateRef(ref, schema, forLocals);
146
+ if (error) {
147
+ errors.push(`Passage "${name}": ${error}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ return errors;
153
+ }
154
+
155
+ /**
156
+ * Extract default values from the schema as a plain object.
157
+ */
158
+ export function extractDefaults(
159
+ schema: Map<string, VariableSchema>,
160
+ ): Record<string, unknown> {
161
+ const defaults: Record<string, unknown> = {};
162
+ for (const [name, varSchema] of schema) {
163
+ defaults[name] = varSchema.default;
164
+ }
165
+ return defaults;
166
+ }