yarn-spinner-runner-ts 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 (114) hide show
  1. package/README.md +180 -0
  2. package/dist/compile/compiler.d.ts +9 -0
  3. package/dist/compile/compiler.js +172 -0
  4. package/dist/compile/compiler.js.map +1 -0
  5. package/dist/compile/ir.d.ts +47 -0
  6. package/dist/compile/ir.js +2 -0
  7. package/dist/compile/ir.js.map +1 -0
  8. package/dist/index.d.ts +10 -0
  9. package/dist/index.js +11 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/model/ast.d.ts +72 -0
  12. package/dist/model/ast.js +2 -0
  13. package/dist/model/ast.js.map +1 -0
  14. package/dist/parse/lexer.d.ts +7 -0
  15. package/dist/parse/lexer.js +78 -0
  16. package/dist/parse/lexer.js.map +1 -0
  17. package/dist/parse/parser.d.ts +4 -0
  18. package/dist/parse/parser.js +433 -0
  19. package/dist/parse/parser.js.map +1 -0
  20. package/dist/runtime/commands.d.ts +31 -0
  21. package/dist/runtime/commands.js +157 -0
  22. package/dist/runtime/commands.js.map +1 -0
  23. package/dist/runtime/evaluator.d.ts +52 -0
  24. package/dist/runtime/evaluator.js +309 -0
  25. package/dist/runtime/evaluator.js.map +1 -0
  26. package/dist/runtime/results.d.ts +22 -0
  27. package/dist/runtime/results.js +2 -0
  28. package/dist/runtime/results.js.map +1 -0
  29. package/dist/runtime/runner.d.ts +63 -0
  30. package/dist/runtime/runner.js +456 -0
  31. package/dist/runtime/runner.js.map +1 -0
  32. package/dist/tests/full_featured.test.d.ts +1 -0
  33. package/dist/tests/full_featured.test.js +130 -0
  34. package/dist/tests/full_featured.test.js.map +1 -0
  35. package/dist/tests/index.test.d.ts +1 -0
  36. package/dist/tests/index.test.js +30 -0
  37. package/dist/tests/index.test.js.map +1 -0
  38. package/dist/tests/jump_detour.test.d.ts +1 -0
  39. package/dist/tests/jump_detour.test.js +47 -0
  40. package/dist/tests/jump_detour.test.js.map +1 -0
  41. package/dist/tests/nodes_lines.test.d.ts +1 -0
  42. package/dist/tests/nodes_lines.test.js +23 -0
  43. package/dist/tests/nodes_lines.test.js.map +1 -0
  44. package/dist/tests/once.test.d.ts +1 -0
  45. package/dist/tests/once.test.js +29 -0
  46. package/dist/tests/once.test.js.map +1 -0
  47. package/dist/tests/options.test.d.ts +1 -0
  48. package/dist/tests/options.test.js +32 -0
  49. package/dist/tests/options.test.js.map +1 -0
  50. package/dist/tests/variables_flow_cmds.test.d.ts +1 -0
  51. package/dist/tests/variables_flow_cmds.test.js +30 -0
  52. package/dist/tests/variables_flow_cmds.test.js.map +1 -0
  53. package/dist/types.d.ts +3 -0
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -0
  56. package/docs/commands.md +21 -0
  57. package/docs/compatibility-checklist.md +77 -0
  58. package/docs/css-attribute.md +47 -0
  59. package/docs/detour.md +24 -0
  60. package/docs/enums.md +25 -0
  61. package/docs/flow-control.md +25 -0
  62. package/docs/functions.md +20 -0
  63. package/docs/jumps.md +24 -0
  64. package/docs/line-groups.md +21 -0
  65. package/docs/lines-nodes-and-options.md +30 -0
  66. package/docs/logic-and-variables.md +25 -0
  67. package/docs/markup.md +19 -0
  68. package/docs/node-groups.md +13 -0
  69. package/docs/once.md +21 -0
  70. package/docs/options.md +45 -0
  71. package/docs/saliency.md +25 -0
  72. package/docs/scenes-actors-setup.md +195 -0
  73. package/docs/scenes.md +64 -0
  74. package/docs/shadow-lines.md +18 -0
  75. package/docs/smart-variables.md +19 -0
  76. package/docs/storylets-and-saliency-a-primer.md +14 -0
  77. package/docs/tags-metadata.md +18 -0
  78. package/eslint.config.cjs +33 -0
  79. package/examples/browser/README.md +40 -0
  80. package/examples/browser/index.html +23 -0
  81. package/examples/browser/main.tsx +16 -0
  82. package/examples/browser/vite.config.ts +22 -0
  83. package/examples/react/DialogueExample.tsx +2 -0
  84. package/examples/react/DialogueView.tsx +2 -0
  85. package/examples/react/useYarnRunner.tsx +2 -0
  86. package/examples/scenes/scenes.yaml +10 -0
  87. package/examples/yarn/full_featured.yarn +43 -0
  88. package/package.json +55 -0
  89. package/src/compile/compiler.ts +183 -0
  90. package/src/compile/ir.ts +28 -0
  91. package/src/index.ts +17 -0
  92. package/src/model/ast.ts +93 -0
  93. package/src/parse/lexer.ts +108 -0
  94. package/src/parse/parser.ts +435 -0
  95. package/src/react/DialogueExample.tsx +149 -0
  96. package/src/react/DialogueScene.tsx +107 -0
  97. package/src/react/DialogueView.tsx +160 -0
  98. package/src/react/dialogue.css +181 -0
  99. package/src/react/useYarnRunner.tsx +33 -0
  100. package/src/runtime/commands.ts +183 -0
  101. package/src/runtime/evaluator.ts +327 -0
  102. package/src/runtime/results.ts +27 -0
  103. package/src/runtime/runner.ts +480 -0
  104. package/src/scene/parser.ts +83 -0
  105. package/src/scene/types.ts +17 -0
  106. package/src/tests/full_featured.test.ts +131 -0
  107. package/src/tests/index.test.ts +34 -0
  108. package/src/tests/jump_detour.test.ts +47 -0
  109. package/src/tests/nodes_lines.test.ts +27 -0
  110. package/src/tests/once.test.ts +32 -0
  111. package/src/tests/options.test.ts +34 -0
  112. package/src/tests/variables_flow_cmds.test.ts +33 -0
  113. package/src/types.ts +4 -0
  114. package/tsconfig.json +21 -0
@@ -0,0 +1,480 @@
1
+ import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
2
+ import type { RuntimeResult } from "./results.js";
3
+ import { ExpressionEvaluator } from "./evaluator.js";
4
+ import { CommandHandler, parseCommand } from "./commands.js";
5
+
6
+ export interface RunnerOptions {
7
+ startAt: string;
8
+ variables?: Record<string, unknown>;
9
+ functions?: Record<string, (...args: unknown[]) => unknown>;
10
+ handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
11
+ commandHandler?: CommandHandler;
12
+ }
13
+
14
+ const globalOnceSeen = new Set<string>();
15
+ const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
16
+
17
+ export class YarnRunner {
18
+ private readonly program: IRProgram;
19
+ private readonly variables: Record<string, unknown>;
20
+ private readonly functions: Record<string, (...args: unknown[]) => unknown>;
21
+ private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
22
+ private readonly commandHandler: CommandHandler;
23
+ private readonly evaluator: ExpressionEvaluator;
24
+ private readonly onceSeen = globalOnceSeen;
25
+ private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
26
+ private readonly visitCounts: Record<string, number> = {};
27
+
28
+ private nodeTitle: string;
29
+ private ip = 0; // instruction pointer within node
30
+ private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
31
+ private callStack: Array<
32
+ | ({ title: string; ip: number } & { kind: "detour" })
33
+ | ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
34
+ > = [];
35
+
36
+ currentResult: RuntimeResult | null = null;
37
+ history: RuntimeResult[] = [];
38
+
39
+ constructor(program: IRProgram, opts: RunnerOptions) {
40
+ this.program = program;
41
+ this.variables = { ...(opts.variables ?? {}) };
42
+ this.functions = {
43
+ // Default conversion helpers
44
+ string: (v: unknown) => String(v ?? ""),
45
+ number: (v: unknown) => Number(v),
46
+ bool: (v: unknown) => Boolean(v),
47
+ visited: (nodeName: unknown) => {
48
+ const name = String(nodeName ?? "");
49
+ return (this.visitCounts[name] ?? 0) > 0;
50
+ },
51
+ visited_count: (nodeName: unknown) => {
52
+ const name = String(nodeName ?? "");
53
+ return this.visitCounts[name] ?? 0;
54
+ },
55
+ format_invariant: (n: unknown) => {
56
+ const num = Number(n);
57
+ if (!isFinite(num)) return "0";
58
+ return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
59
+ },
60
+ random: () => Math.random(),
61
+ random_range: (a: unknown, b: unknown) => {
62
+ const x = Number(a), y = Number(b);
63
+ const min = Math.min(x, y);
64
+ const max = Math.max(x, y);
65
+ return min + Math.random() * (max - min);
66
+ },
67
+ dice: (sides: unknown) => {
68
+ const s = Math.max(1, Math.floor(Number(sides)) || 1);
69
+ return Math.floor(Math.random() * s) + 1;
70
+ },
71
+ min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
72
+ max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
73
+ round: (n: unknown) => Math.round(Number(n)),
74
+ round_places: (n: unknown, places: unknown) => {
75
+ const p = Math.max(0, Math.floor(Number(places)) || 0);
76
+ const factor = Math.pow(10, p);
77
+ return Math.round(Number(n) * factor) / factor;
78
+ },
79
+ floor: (n: unknown) => Math.floor(Number(n)),
80
+ ceil: (n: unknown) => Math.ceil(Number(n)),
81
+ inc: (n: unknown) => {
82
+ const v = Number(n);
83
+ return Number.isInteger(v) ? v + 1 : Math.ceil(v);
84
+ },
85
+ dec: (n: unknown) => {
86
+ const v = Number(n);
87
+ return Number.isInteger(v) ? v - 1 : Math.floor(v);
88
+ },
89
+ decimal: (n: unknown) => {
90
+ const v = Number(n);
91
+ return Math.abs(v - Math.trunc(v));
92
+ },
93
+ int: (n: unknown) => Math.trunc(Number(n)),
94
+ ...(opts.functions ?? {}),
95
+ } as Record<string, (...args: unknown[]) => unknown>;
96
+ this.handleCommand = opts.handleCommand;
97
+ this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
98
+ this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
99
+ this.nodeTitle = opts.startAt;
100
+
101
+ this.step();
102
+ }
103
+
104
+ /**
105
+ * Get the current node title (may resolve to a node group).
106
+ */
107
+ getCurrentNodeTitle(): string {
108
+ return this.nodeTitle;
109
+ }
110
+
111
+ /**
112
+ * Resolve a node title to an actual node (handling node groups).
113
+ */
114
+ private resolveNode(title: string): IRNode {
115
+ const nodeOrGroup = this.program.nodes[title];
116
+ if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
117
+
118
+ // If it's a single node, return it
119
+ if (!("nodes" in nodeOrGroup)) {
120
+ this.currentNodeIndex = -1;
121
+ return nodeOrGroup as IRNode;
122
+ }
123
+
124
+ // It's a node group - select the first matching node based on when conditions
125
+ const group = nodeOrGroup as IRNodeGroup;
126
+ for (let i = 0; i < group.nodes.length; i++) {
127
+ const candidate = group.nodes[i];
128
+ if (this.evaluateWhenConditions(candidate.when, title, i)) {
129
+ this.currentNodeIndex = i;
130
+ // If "once" condition, mark as seen immediately
131
+ if (candidate.when?.includes("once")) {
132
+ this.markNodeGroupOnceSeen(title, i);
133
+ }
134
+ return candidate;
135
+ }
136
+ }
137
+
138
+ // No matching node found - throw error or return first? Docs suggest error if no match
139
+ throw new Error(`No matching node found in group ${title}`);
140
+ }
141
+
142
+ /**
143
+ * Evaluate when conditions for a node in a group.
144
+ */
145
+ private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
146
+ if (!conditions || conditions.length === 0) {
147
+ // No when condition - available by default (but should not happen in groups)
148
+ return true;
149
+ }
150
+
151
+ // All conditions must be true (AND logic)
152
+ for (const condition of conditions) {
153
+ const trimmed = condition.trim();
154
+
155
+ if (trimmed === "once") {
156
+ // Check if this node has been visited once
157
+ const onceKey = `${nodeTitle}#${nodeIndex}`;
158
+ if (this.nodeGroupOnceSeen.has(onceKey)) {
159
+ return false; // Already seen once
160
+ }
161
+ // Will mark as seen when node is entered
162
+ continue;
163
+ }
164
+
165
+ if (trimmed === "always") {
166
+ // Always available
167
+ continue;
168
+ }
169
+
170
+ // Otherwise, treat as expression (e.g., "$has_sword")
171
+ if (!this.evaluator.evaluate(trimmed)) {
172
+ return false; // Condition failed
173
+ }
174
+ }
175
+
176
+ return true; // All conditions passed
177
+ }
178
+
179
+ /**
180
+ * Mark a node group node as seen (for "once" condition).
181
+ */
182
+ private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
183
+ const onceKey = `${nodeTitle}#${nodeIndex}`;
184
+ this.nodeGroupOnceSeen.add(onceKey);
185
+ }
186
+
187
+ advance(optionIndex?: number) {
188
+ // If awaiting option selection, consume chosen option by pushing its block
189
+ if (this.currentResult?.type === "options") {
190
+ if (optionIndex == null) throw new Error("Option index required");
191
+ // Resolve to actual node (handles groups)
192
+ const node = this.resolveNode(this.nodeTitle);
193
+ // We encoded options at ip-1; locate it
194
+ const ins = node.instructions[this.ip - 1];
195
+ if (ins?.op !== "options") throw new Error("Invalid options state");
196
+ const chosen = ins.options[optionIndex];
197
+ if (!chosen) throw new Error("Invalid option index");
198
+ // Push a block frame that we will resume across advances
199
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
200
+ if (this.resumeBlock()) return;
201
+ return;
202
+ }
203
+ // If we have a pending block, resume it first
204
+ if (this.resumeBlock()) return;
205
+ this.step();
206
+ }
207
+
208
+ private interpolate(text: string): string {
209
+ return text.replace(/\{([^}]+)\}/g, (_m, expr) => {
210
+ try {
211
+ const val = this.evaluator.evaluateExpression(String(expr));
212
+ return String(val ?? "");
213
+ } catch {
214
+ return "";
215
+ }
216
+ });
217
+ }
218
+
219
+ private resumeBlock(): boolean {
220
+ const top = this.callStack[this.callStack.length - 1];
221
+ if (!top || top.kind !== "block") return false;
222
+ // Execute from stored idx until we emit one result or finish block
223
+ while (true) {
224
+ const ins = top.block[top.idx++];
225
+ if (!ins) {
226
+ // finished block; pop and continue main step
227
+ this.callStack.pop();
228
+ this.step();
229
+ return true;
230
+ }
231
+ switch (ins.op) {
232
+ case "line":
233
+ this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, isDialogueEnd: false });
234
+ return true;
235
+ case "command": {
236
+ try {
237
+ const parsed = parseCommand(ins.content);
238
+ this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
239
+ if (this.handleCommand) this.handleCommand(ins.content, parsed);
240
+ } catch {
241
+ if (this.handleCommand) this.handleCommand(ins.content);
242
+ }
243
+ this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
244
+ return true;
245
+ }
246
+ case "options": {
247
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags })), isDialogueEnd: false });
248
+ return true;
249
+ }
250
+ case "if": {
251
+ const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
252
+ if (branch) {
253
+ // Push nested block at current top position (resume after)
254
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
255
+ return this.resumeBlock();
256
+ }
257
+ break;
258
+ }
259
+ case "once": {
260
+ if (!this.onceSeen.has(ins.id)) {
261
+ this.onceSeen.add(ins.id);
262
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
263
+ return this.resumeBlock();
264
+ }
265
+ break;
266
+ }
267
+ case "jump": {
268
+ this.nodeTitle = ins.target;
269
+ this.ip = 0;
270
+ this.step();
271
+ return true;
272
+ }
273
+ case "detour": {
274
+ this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
275
+ this.nodeTitle = ins.target;
276
+ this.ip = 0;
277
+ this.step();
278
+ return true;
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ private step() {
285
+ while (true) {
286
+ const resolved = this.resolveNode(this.nodeTitle);
287
+ const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
288
+ const ins = currentNode.instructions[this.ip];
289
+ if (!ins) {
290
+ // Node ended
291
+ this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
292
+ this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
293
+ return;
294
+ }
295
+ this.ip++;
296
+ switch (ins.op) {
297
+ case "line":
298
+ this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
299
+ return;
300
+ case "command": {
301
+ try {
302
+ const parsed = parseCommand(ins.content);
303
+ this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
304
+ if (this.handleCommand) this.handleCommand(ins.content, parsed);
305
+ } catch {
306
+ if (this.handleCommand) this.handleCommand(ins.content);
307
+ }
308
+ this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
309
+ return;
310
+ }
311
+ case "jump": {
312
+ // Exiting current node due to jump
313
+ this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
314
+ this.nodeTitle = ins.target;
315
+ this.ip = 0;
316
+ this.currentNodeIndex = -1; // Reset node index for new resolution
317
+ // resolveNode will handle node groups
318
+ continue;
319
+ }
320
+ case "detour": {
321
+ // Save return position, jump to target node, return when it ends
322
+ this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
323
+ this.nodeTitle = ins.target;
324
+ this.ip = 0;
325
+ this.currentNodeIndex = -1; // Reset node index for new resolution
326
+ // resolveNode will handle node groups
327
+ continue;
328
+ }
329
+ case "options": {
330
+ this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string }) => ({ text: o.text, tags: o.tags, css: o.css })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
331
+ return;
332
+ }
333
+ case "if": {
334
+ const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
335
+ if (branch) {
336
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
337
+ if (this.resumeBlock()) return;
338
+ }
339
+ break;
340
+ }
341
+ case "once": {
342
+ if (!this.onceSeen.has(ins.id)) {
343
+ this.onceSeen.add(ins.id);
344
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
345
+ if (this.resumeBlock()) return;
346
+ }
347
+ break;
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ private executeBlock(block: { title: string; instructions: IRInstruction[] }) {
354
+ // Execute instructions of block, then resume
355
+ const saved = { title: this.nodeTitle, ip: this.ip } as const;
356
+ this.nodeTitle = block.title;
357
+ const tempIpStart = 0;
358
+ const tempNode = { title: block.title, instructions: block.instructions } as const;
359
+ // Use a temporary node context
360
+ const restore = () => {
361
+ this.nodeTitle = saved.title;
362
+ this.ip = saved.ip;
363
+ };
364
+
365
+ // Step through block, emitting first result
366
+ let idx = tempIpStart;
367
+ while (true) {
368
+ const ins = tempNode.instructions[idx++];
369
+ if (!ins) break;
370
+ switch (ins.op) {
371
+ case "line":
372
+ this.emit({ type: "text", text: ins.text, speaker: ins.speaker, isDialogueEnd: false });
373
+ restore();
374
+ return;
375
+ case "command":
376
+ try {
377
+ const parsed = parseCommand(ins.content);
378
+ this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
379
+ if (this.handleCommand) this.handleCommand(ins.content, parsed);
380
+ } catch {
381
+ if (this.handleCommand) this.handleCommand(ins.content);
382
+ }
383
+ this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
384
+ restore();
385
+ return;
386
+ case "options":
387
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text })), isDialogueEnd: false });
388
+ // Maintain context that options belong to main node at ip-1
389
+ restore();
390
+ return;
391
+ case "if": {
392
+ const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
393
+ if (branch) {
394
+ // enqueue nested block and resume from main context
395
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
396
+ restore();
397
+ if (this.resumeBlock()) return;
398
+ return;
399
+ }
400
+ break;
401
+ }
402
+ case "once": {
403
+ if (!this.onceSeen.has(ins.id)) {
404
+ this.onceSeen.add(ins.id);
405
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
406
+ restore();
407
+ if (this.resumeBlock()) return;
408
+ return;
409
+ }
410
+ break;
411
+ }
412
+ case "jump": {
413
+ this.nodeTitle = ins.target;
414
+ this.ip = 0;
415
+ this.step();
416
+ return;
417
+ }
418
+ case "detour": {
419
+ this.callStack.push({ kind: "detour", title: saved.title, ip: saved.ip });
420
+ this.nodeTitle = ins.target;
421
+ this.ip = 0;
422
+ this.step();
423
+ return;
424
+ }
425
+ }
426
+ }
427
+ // Block produced no output; resume
428
+ restore();
429
+ this.step();
430
+ }
431
+
432
+ private lookaheadIsEnd(): boolean {
433
+ // Check if current node has more emit-worthy instructions
434
+ const node = this.resolveNode(this.nodeTitle);
435
+ for (let k = this.ip; k < node.instructions.length; k++) {
436
+ const op = node.instructions[k]?.op;
437
+ if (!op) break;
438
+ if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
439
+ if (op === "jump" || op === "detour") return false;
440
+ }
441
+ // Node is ending - mark as end (will trigger detour return if callStack exists)
442
+ return true;
443
+ }
444
+
445
+ private emit(res: RuntimeResult) {
446
+ this.currentResult = res;
447
+ this.history.push(res);
448
+ // If we ended a detour node, return to caller after emitting last result
449
+ // Position is restored here, but we wait for next advance() to continue
450
+ if (res.isDialogueEnd && this.callStack.length > 0) {
451
+ const frame = this.callStack.pop()!;
452
+ this.nodeTitle = frame.title;
453
+ this.ip = frame.ip;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get the current variable store (read-only view).
459
+ */
460
+ getVariables(): Readonly<Record<string, unknown>> {
461
+ return { ...this.variables };
462
+ }
463
+
464
+ /**
465
+ * Get variable value.
466
+ */
467
+ getVariable(name: string): unknown {
468
+ return this.variables[name];
469
+ }
470
+
471
+ /**
472
+ * Set variable value.
473
+ */
474
+ setVariable(name: string, value: unknown): void {
475
+ this.variables[name] = value;
476
+ this.evaluator.setVariable(name, value);
477
+ }
478
+ }
479
+
480
+
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Scene configuration parser using js-yaml
3
+ * Supports YAML string or plain object
4
+ */
5
+
6
+ import yaml from "js-yaml";
7
+ import type { SceneCollection, SceneConfig, ActorConfig } from "./types.js";
8
+
9
+ /**
10
+ * Parse scene configuration from YAML string or object
11
+ */
12
+ export function parseScenes(input: string | Record<string, unknown>): SceneCollection {
13
+ // If already an object, use it directly
14
+ if (typeof input === "object" && input !== null) {
15
+ return parseScenesFromObject(input);
16
+ }
17
+
18
+ // Parse YAML string
19
+ try {
20
+ const parsed = yaml.load(input) as Record<string, unknown>;
21
+ return parseScenesFromObject(parsed || {});
22
+ } catch (error) {
23
+ console.error("Failed to parse YAML scene config:", error);
24
+ throw new Error(`Invalid YAML scene configuration: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+
28
+ function parseScenesFromObject(obj: Record<string, unknown>): SceneCollection {
29
+ const scenes: Record<string, SceneConfig> = {};
30
+
31
+ // Extract global actors if defined separately
32
+ const globalActors: Record<string, ActorConfig> = {};
33
+ if (typeof obj.actors === "object" && obj.actors !== null) {
34
+ for (const [actorName, actorData] of Object.entries(obj.actors)) {
35
+ if (typeof actorData === "object" && actorData !== null) {
36
+ // Nested format: actorName: { image: "..." }
37
+ globalActors[actorName] = {
38
+ image: typeof actorData.image === "string" ? actorData.image : undefined,
39
+ };
40
+ } else if (typeof actorData === "string") {
41
+ // Shorthand: actorName: "/path/to/image.png"
42
+ globalActors[actorName] = { image: actorData };
43
+ }
44
+ }
45
+ }
46
+
47
+ // Parse scenes
48
+ if (typeof obj.scenes === "object" && obj.scenes !== null) {
49
+ const scenesObj = obj.scenes as Record<string, unknown>;
50
+ for (const [sceneName, sceneData] of Object.entries(scenesObj)) {
51
+ if (typeof sceneData === "object" && sceneData !== null) {
52
+ const data = sceneData as Record<string, unknown>;
53
+ const sceneActors: Record<string, ActorConfig> = { ...globalActors }; // Start with global actors
54
+
55
+ // Override with scene-specific actors if defined
56
+ if (typeof data.actors === "object" && data.actors !== null) {
57
+ for (const [actorName, actorData] of Object.entries(data.actors)) {
58
+ if (typeof actorData === "object" && actorData !== null) {
59
+ sceneActors[actorName] = {
60
+ image: typeof actorData.image === "string" ? actorData.image : undefined,
61
+ };
62
+ } else if (typeof actorData === "string") {
63
+ sceneActors[actorName] = { image: actorData };
64
+ }
65
+ }
66
+ }
67
+
68
+ scenes[sceneName] = {
69
+ background: typeof data.background === "string" ? data.background : "",
70
+ actors: sceneActors,
71
+ };
72
+ } else if (typeof sceneData === "string") {
73
+ // Shorthand: scene1: "/path/to/background.png" (uses global actors)
74
+ scenes[sceneName] = {
75
+ background: sceneData,
76
+ actors: { ...globalActors },
77
+ };
78
+ }
79
+ }
80
+ }
81
+
82
+ return { scenes };
83
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Scene configuration types
3
+ */
4
+
5
+ export interface ActorConfig {
6
+ image?: string; // Path to actor image
7
+ }
8
+
9
+ export interface SceneConfig {
10
+ background: string; // Path to background image
11
+ actors: Record<string, ActorConfig>; // Actor name -> config
12
+ }
13
+
14
+ export interface SceneCollection {
15
+ scenes: Record<string, SceneConfig>; // Scene name -> config
16
+ }
17
+