yarn-spinner-runner-ts 0.1.4 → 0.1.5-a

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 (54) hide show
  1. package/README.md +44 -16
  2. package/dist/compile/compiler.js +2 -2
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +1 -0
  5. package/dist/markup/parser.js +12 -2
  6. package/dist/markup/parser.js.map +1 -1
  7. package/dist/model/ast.d.ts +1 -0
  8. package/dist/parse/lexer.js +5 -5
  9. package/dist/parse/lexer.js.map +1 -1
  10. package/dist/parse/parser.js +12 -2
  11. package/dist/parse/parser.js.map +1 -1
  12. package/dist/react/DialogueExample.js +12 -10
  13. package/dist/react/DialogueExample.js.map +1 -1
  14. package/dist/react/DialogueView.d.ts +10 -4
  15. package/dist/react/DialogueView.js +38 -12
  16. package/dist/react/DialogueView.js.map +1 -1
  17. package/dist/react/MarkupRenderer.js +1 -1
  18. package/dist/react/MarkupRenderer.js.map +1 -1
  19. package/dist/react/useYarnRunner.js +63 -10
  20. package/dist/react/useYarnRunner.js.map +1 -1
  21. package/dist/runtime/evaluator.d.ts +3 -0
  22. package/dist/runtime/evaluator.js +165 -3
  23. package/dist/runtime/evaluator.js.map +1 -1
  24. package/dist/runtime/runner.d.ts +2 -0
  25. package/dist/runtime/runner.js +66 -10
  26. package/dist/runtime/runner.js.map +1 -1
  27. package/dist/tests/dialogue_view.test.d.ts +1 -0
  28. package/dist/tests/dialogue_view.test.js +18 -0
  29. package/dist/tests/dialogue_view.test.js.map +1 -0
  30. package/dist/tests/markup.test.js +7 -0
  31. package/dist/tests/markup.test.js.map +1 -1
  32. package/dist/tests/options.test.js +164 -9
  33. package/dist/tests/options.test.js.map +1 -1
  34. package/dist/tests/variables_flow_cmds.test.js +117 -10
  35. package/dist/tests/variables_flow_cmds.test.js.map +1 -1
  36. package/docs/markup.md +33 -33
  37. package/eslint.config.cjs +39 -39
  38. package/package.json +6 -6
  39. package/src/compile/compiler.ts +2 -2
  40. package/src/compile/ir.ts +1 -1
  41. package/src/markup/parser.ts +53 -43
  42. package/src/model/ast.ts +1 -0
  43. package/src/parse/lexer.ts +18 -18
  44. package/src/parse/parser.ts +33 -22
  45. package/src/react/DialogueExample.tsx +16 -14
  46. package/src/react/DialogueView.tsx +312 -275
  47. package/src/react/MarkupRenderer.tsx +1 -2
  48. package/src/react/useYarnRunner.tsx +101 -34
  49. package/src/runtime/evaluator.ts +224 -47
  50. package/src/runtime/runner.ts +102 -37
  51. package/src/tests/dialogue_view.test.tsx +26 -0
  52. package/src/tests/markup.test.ts +17 -1
  53. package/src/tests/options.test.ts +206 -36
  54. package/src/tests/variables_flow_cmds.test.ts +139 -28
@@ -1,35 +1,102 @@
1
- import { useState, useCallback, useRef } from "react";
2
- import { YarnRunner, type RunnerOptions } from "../runtime/runner.js";
3
- import type { IRProgram } from "../compile/ir.js";
4
- import type { RuntimeResult } from "../runtime/results.js";
5
-
6
- export function useYarnRunner(
7
- program: IRProgram,
8
- options: RunnerOptions
9
- ): {
10
- result: RuntimeResult | null;
11
- advance: (optionIndex?: number) => void;
12
- runner: YarnRunner;
13
- } {
14
- const runnerRef = useRef<YarnRunner | null>(null);
15
- const [result, setResult] = useState<RuntimeResult | null>(null);
16
-
17
- // Initialize runner only once
18
- if (!runnerRef.current) {
19
- runnerRef.current = new YarnRunner(program, options);
20
- setResult(runnerRef.current.currentResult);
21
- }
22
-
23
- const runner = runnerRef.current;
24
-
25
- const advance = useCallback(
26
- (optionIndex?: number) => {
27
- runner.advance(optionIndex);
28
- setResult(runner.currentResult);
29
- },
30
- [runner]
31
- );
32
-
33
- return { result, advance, runner };
34
- }
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { YarnRunner, type RunnerOptions } from "../runtime/runner.js";
3
+ import type { IRProgram } from "../compile/ir.js";
4
+ import type { RuntimeResult } from "../runtime/results.js";
5
+
6
+ function haveFunctionsChanged(
7
+ prev: RunnerOptions["functions"],
8
+ next: RunnerOptions["functions"]
9
+ ): boolean {
10
+ const prevFns = prev ?? {};
11
+ const nextFns = next ?? {};
12
+
13
+ const prevKeys = Object.keys(prevFns);
14
+ const nextKeys = Object.keys(nextFns);
15
+
16
+ if (prevKeys.length !== nextKeys.length) {
17
+ return true;
18
+ }
19
+
20
+ for (const key of prevKeys) {
21
+ if (!Object.prototype.hasOwnProperty.call(nextFns, key) || prevFns[key] !== nextFns[key]) {
22
+ return true;
23
+ }
24
+ }
25
+
26
+ return false;
27
+ }
28
+
29
+ function haveVariablesChanged(
30
+ prev: RunnerOptions["variables"],
31
+ next: RunnerOptions["variables"]
32
+ ): boolean {
33
+ const prevVars = prev ?? {};
34
+ const nextVars = next ?? {};
35
+ return JSON.stringify(prevVars) !== JSON.stringify(nextVars);
36
+ }
37
+
38
+ export function useYarnRunner(
39
+ program: IRProgram,
40
+ options: RunnerOptions
41
+ ): {
42
+ result: RuntimeResult | null;
43
+ advance: (optionIndex?: number) => void;
44
+ runner: YarnRunner;
45
+ } {
46
+ const runnerRef = useRef<YarnRunner | null>(null);
47
+ const optionsRef = useRef(options);
48
+ const programRef = useRef(program);
49
+ const [result, setResult] = useState<RuntimeResult | null>(() => {
50
+ const runner = new YarnRunner(program, options);
51
+ runnerRef.current = runner;
52
+ optionsRef.current = options;
53
+ programRef.current = program;
54
+ return runner.currentResult;
55
+ });
56
+
57
+ useEffect(() => {
58
+ const prevProgram = programRef.current;
59
+ const prevOptions = optionsRef.current;
60
+
61
+ const programChanged = prevProgram !== program;
62
+ const functionsChanged = haveFunctionsChanged(prevOptions?.functions, options.functions);
63
+ const startNodeChanged = prevOptions?.startAt !== options.startAt;
64
+ const variablesChanged = haveVariablesChanged(prevOptions?.variables, options.variables);
65
+ const handlersChanged =
66
+ prevOptions?.handleCommand !== options.handleCommand ||
67
+ prevOptions?.commandHandler !== options.commandHandler ||
68
+ prevOptions?.onStoryEnd !== options.onStoryEnd;
69
+
70
+ if (
71
+ !runnerRef.current ||
72
+ programChanged ||
73
+ functionsChanged ||
74
+ startNodeChanged ||
75
+ variablesChanged ||
76
+ handlersChanged
77
+ ) {
78
+ const runner = new YarnRunner(program, options);
79
+ runnerRef.current = runner;
80
+ setResult(runner.currentResult);
81
+ }
82
+
83
+ programRef.current = program;
84
+ optionsRef.current = options;
85
+ }, [program, options]);
86
+
87
+ const advance = useCallback((optionIndex?: number) => {
88
+ const runner = runnerRef.current;
89
+ if (!runner) {
90
+ return;
91
+ }
92
+ runner.advance(optionIndex);
93
+ setResult(runner.currentResult);
94
+ }, []);
95
+
96
+ return {
97
+ result,
98
+ advance,
99
+ runner: runnerRef.current as YarnRunner,
100
+ };
101
+ }
35
102
 
@@ -27,33 +27,38 @@ export class ExpressionEvaluator {
27
27
  /**
28
28
  * Evaluate an expression that can return any value (not just boolean).
29
29
  */
30
- evaluateExpression(expr: string): unknown {
31
- const trimmed = this.preprocess(expr.trim());
32
- if (!trimmed) return false;
33
-
34
- // Handle function calls like `functionName(arg1, arg2)`
35
- if (trimmed.includes("(") && trimmed.includes(")")) {
36
- return this.evaluateFunctionCall(trimmed);
37
- }
38
-
39
- // Handle comparisons
40
- if (this.containsComparison(trimmed)) {
41
- return this.evaluateComparison(trimmed);
42
- }
43
-
44
- // Handle logical operators
45
- if (trimmed.includes("&&") || trimmed.includes("||")) {
46
- return this.evaluateLogical(trimmed);
47
- }
48
-
49
- // Handle negation
50
- if (trimmed.startsWith("!")) {
51
- return !this.evaluateExpression(trimmed.slice(1).trim());
52
- }
53
-
54
- // Simple variable or literal
55
- return this.resolveValue(trimmed);
56
- }
30
+ evaluateExpression(expr: string): unknown {
31
+ const trimmed = this.preprocess(expr.trim());
32
+ if (!trimmed) return false;
33
+
34
+ // Handle function calls like `functionName(arg1, arg2)`
35
+ if (this.looksLikeFunctionCall(trimmed)) {
36
+ return this.evaluateFunctionCall(trimmed);
37
+ }
38
+
39
+ // Handle comparisons
40
+ if (this.containsComparison(trimmed)) {
41
+ return this.evaluateComparison(trimmed);
42
+ }
43
+
44
+ // Handle logical operators
45
+ if (trimmed.includes("&&") || trimmed.includes("||")) {
46
+ return this.evaluateLogical(trimmed);
47
+ }
48
+
49
+ // Handle negation
50
+ if (trimmed.startsWith("!")) {
51
+ return !this.evaluateExpression(trimmed.slice(1).trim());
52
+ }
53
+
54
+ // Handle arithmetic expressions (+, -, *, /, %)
55
+ if (this.containsArithmetic(trimmed)) {
56
+ return this.evaluateArithmetic(trimmed);
57
+ }
58
+
59
+ // Simple variable or literal
60
+ return this.resolveValue(trimmed);
61
+ }
57
62
 
58
63
  private preprocess(expr: string): string {
59
64
  // Normalize operator word aliases to JS-like symbols
@@ -71,9 +76,9 @@ export class ExpressionEvaluator {
71
76
  .replace(/\blt\b/gi, "<");
72
77
  }
73
78
 
74
- private evaluateFunctionCall(expr: string): unknown {
75
- const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
76
- if (!match) throw new Error(`Invalid function call: ${expr}`);
79
+ private evaluateFunctionCall(expr: string): unknown {
80
+ const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
81
+ if (!match) throw new Error(`Invalid function call: ${expr}`);
77
82
 
78
83
  const [, name, argsStr] = match;
79
84
  const func = this.functions[name];
@@ -104,26 +109,198 @@ export class ExpressionEvaluator {
104
109
  return args;
105
110
  }
106
111
 
107
- private containsComparison(expr: string): boolean {
108
- return /[<>=!]/.test(expr);
109
- }
112
+ private containsComparison(expr: string): boolean {
113
+ return /[<>=!]/.test(expr);
114
+ }
115
+
116
+ private looksLikeFunctionCall(expr: string): boolean {
117
+ return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
118
+ }
119
+
120
+ private containsArithmetic(expr: string): boolean {
121
+ // Remove quoted strings to avoid false positives on "-" or "+" inside literals
122
+ const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
123
+ return /[+\-*/%]/.test(unquoted);
124
+ }
125
+
126
+ private evaluateArithmetic(expr: string): number {
127
+ const input = expr;
128
+ let index = 0;
129
+
130
+ const skipWhitespace = () => {
131
+ while (index < input.length && /\s/.test(input[index])) {
132
+ index++;
133
+ }
134
+ };
135
+
136
+ const toNumber = (value: unknown): number => {
137
+ if (typeof value === "number") return value;
138
+ if (typeof value === "boolean") return value ? 1 : 0;
139
+ if (value == null || value === "") return 0;
140
+ const num = Number(value);
141
+ if (Number.isNaN(num)) {
142
+ throw new Error(`Cannot convert ${String(value)} to number`);
143
+ }
144
+ return num;
145
+ };
146
+
147
+ const readToken = (): string => {
148
+ skipWhitespace();
149
+ const start = index;
150
+ let depth = 0;
151
+ let inQuotes = false;
152
+ let quoteChar = "";
153
+
154
+ while (index < input.length) {
155
+ const char = input[index];
156
+ if (inQuotes) {
157
+ if (char === quoteChar) {
158
+ inQuotes = false;
159
+ quoteChar = "";
160
+ }
161
+ index++;
162
+ continue;
163
+ }
164
+
165
+ if (char === '"' || char === "'") {
166
+ inQuotes = true;
167
+ quoteChar = char;
168
+ index++;
169
+ continue;
170
+ }
171
+
172
+ if (char === "(") {
173
+ depth++;
174
+ index++;
175
+ continue;
176
+ }
177
+
178
+ if (char === ")") {
179
+ if (depth === 0) break;
180
+ depth--;
181
+ index++;
182
+ continue;
183
+ }
184
+
185
+ if (depth === 0 && "+-*/%".includes(char)) {
186
+ break;
187
+ }
188
+
189
+ if (depth === 0 && /\s/.test(char)) {
190
+ break;
191
+ }
192
+
193
+ index++;
194
+ }
195
+
196
+ return input.slice(start, index).trim();
197
+ };
198
+
199
+ const parsePrimary = (): unknown => {
200
+ skipWhitespace();
201
+ if (index >= input.length) {
202
+ throw new Error("Unexpected end of expression");
203
+ }
204
+
205
+ const char = input[index];
206
+ if (char === "(") {
207
+ index++;
208
+ const value = parseAddSub();
209
+ skipWhitespace();
210
+ if (input[index] !== ")") {
211
+ throw new Error("Unmatched parenthesis in expression");
212
+ }
213
+ index++;
214
+ return value;
215
+ }
216
+
217
+ const token = readToken();
218
+ if (!token) {
219
+ throw new Error("Invalid expression token");
220
+ }
221
+ return this.evaluateExpression(token);
222
+ };
223
+
224
+ const parseUnary = (): number => {
225
+ skipWhitespace();
226
+ if (input[index] === "+") {
227
+ index++;
228
+ return parseUnary();
229
+ }
230
+ if (input[index] === "-") {
231
+ index++;
232
+ return -parseUnary();
233
+ }
234
+ return toNumber(parsePrimary());
235
+ };
236
+
237
+ const parseMulDiv = (): number => {
238
+ let value = parseUnary();
239
+ while (true) {
240
+ skipWhitespace();
241
+ const char = input[index];
242
+ if (char === "*" || char === "/" || char === "%") {
243
+ index++;
244
+ const right = parseUnary();
245
+ if (char === "*") {
246
+ value = value * right;
247
+ } else if (char === "/") {
248
+ value = value / right;
249
+ } else {
250
+ value = value % right;
251
+ }
252
+ continue;
253
+ }
254
+ break;
255
+ }
256
+ return value;
257
+ };
258
+
259
+ const parseAddSub = (): number => {
260
+ let value = parseMulDiv();
261
+ while (true) {
262
+ skipWhitespace();
263
+ const char = input[index];
264
+ if (char === "+" || char === "-") {
265
+ index++;
266
+ const right = parseMulDiv();
267
+ if (char === "+") {
268
+ value = value + right;
269
+ } else {
270
+ value = value - right;
271
+ }
272
+ continue;
273
+ }
274
+ break;
275
+ }
276
+ return value;
277
+ };
278
+
279
+ const result = parseAddSub();
280
+ skipWhitespace();
281
+ if (index < input.length) {
282
+ throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
283
+ }
284
+ return result;
285
+ }
110
286
 
111
287
  private evaluateComparison(expr: string): boolean {
112
288
  // Match comparison operators (avoid matching !=, <=, >=)
113
- const match = expr.match(/^(.+?)\s*(===|!==|<=|>=|<|>)\s*(.+)$/);
114
- if (!match) throw new Error(`Invalid comparison: ${expr}`);
115
-
116
- const [, left, op, right] = match;
117
- const leftVal = this.evaluateExpression(left.trim());
118
- const rightVal = this.evaluateExpression(right.trim());
119
-
120
- switch (op) {
121
- case "===":
122
- case "==":
123
- return this.deepEquals(leftVal, rightVal);
124
- case "!==":
125
- case "!=":
126
- return !this.deepEquals(leftVal, rightVal);
289
+ const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
290
+ if (!match) throw new Error(`Invalid comparison: ${expr}`);
291
+
292
+ const [, left, rawOp, right] = match;
293
+ const op = rawOp === "=" ? "==" : rawOp;
294
+ const leftVal = this.evaluateExpression(left.trim());
295
+ const rightVal = this.evaluateExpression(right.trim());
296
+
297
+ switch (op) {
298
+ case "===":
299
+ case "==":
300
+ return this.deepEquals(leftVal, rightVal);
301
+ case "!==":
302
+ case "!=":
303
+ return !this.deepEquals(leftVal, rightVal);
127
304
  case "<":
128
305
  return Number(leftVal) < Number(rightVal);
129
306
  case ">":
@@ -1,4 +1,4 @@
1
- import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
1
+ import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
2
2
  import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types.js";
3
3
  import type { RuntimeResult } from "./results.js";
4
4
  import { ExpressionEvaluator } from "./evaluator.js";
@@ -16,7 +16,16 @@ export interface RunnerOptions {
16
16
  const globalOnceSeen = new Set<string>();
17
17
  const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
18
18
 
19
- export class YarnRunner {
19
+ type CompiledOption = {
20
+ text: string;
21
+ tags?: string[];
22
+ css?: string;
23
+ markup?: MarkupParseResult;
24
+ condition?: string;
25
+ block: IRInstruction[];
26
+ };
27
+
28
+ export class YarnRunner {
20
29
  private readonly program: IRProgram;
21
30
  private readonly variables: Record<string, unknown>;
22
31
  private readonly functions: Record<string, (...args: unknown[]) => unknown>;
@@ -27,7 +36,8 @@ export class YarnRunner {
27
36
  private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
28
37
  private storyEnded = false;
29
38
  private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
30
- private readonly visitCounts: Record<string, number> = {};
39
+ private readonly visitCounts: Record<string, number> = {};
40
+ private pendingOptions: CompiledOption[] | null = null;
31
41
 
32
42
  private nodeTitle: string;
33
43
  private ip = 0; // instruction pointer within node
@@ -189,22 +199,20 @@ export class YarnRunner {
189
199
  this.nodeGroupOnceSeen.add(onceKey);
190
200
  }
191
201
 
192
- advance(optionIndex?: number) {
193
- // If awaiting option selection, consume chosen option by pushing its block
194
- if (this.currentResult?.type === "options") {
195
- if (optionIndex == null) throw new Error("Option index required");
196
- // Resolve to actual node (handles groups)
197
- const node = this.resolveNode(this.nodeTitle);
198
- // We encoded options at ip-1; locate it
199
- const ins = node.instructions[this.ip - 1];
200
- if (ins?.op !== "options") throw new Error("Invalid options state");
201
- const chosen = ins.options[optionIndex];
202
- if (!chosen) throw new Error("Invalid option index");
203
- // Push a block frame that we will resume across advances
204
- this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
205
- if (this.resumeBlock()) return;
206
- return;
207
- }
202
+ advance(optionIndex?: number) {
203
+ // If awaiting option selection, consume chosen option by pushing its block
204
+ if (this.currentResult?.type === "options") {
205
+ if (optionIndex == null) throw new Error("Option index required");
206
+ const options = this.pendingOptions;
207
+ if (!options) throw new Error("Invalid options state");
208
+ const chosen = options[optionIndex];
209
+ if (!chosen) throw new Error("Invalid option index");
210
+ // Push a block frame that we will resume across advances
211
+ this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
212
+ this.pendingOptions = null;
213
+ if (this.resumeBlock()) return;
214
+ return;
215
+ }
208
216
  // If we have a pending block, resume it first
209
217
  if (this.resumeBlock()) return;
210
218
  this.step();
@@ -387,10 +395,22 @@ export class YarnRunner {
387
395
  this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
388
396
  return true;
389
397
  }
390
- case "options": {
391
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags, markup: o.markup })), isDialogueEnd: false });
392
- return true;
393
- }
398
+ case "options": {
399
+ const available = this.filterOptions(ins.options);
400
+ if (available.length === 0) {
401
+ continue;
402
+ }
403
+ this.pendingOptions = available;
404
+ this.emit({
405
+ type: "options",
406
+ options: available.map((o) => {
407
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
408
+ return { text: interpolatedText, tags: o.tags, markup: interpolatedMarkup };
409
+ }),
410
+ isDialogueEnd: false,
411
+ });
412
+ return true;
413
+ }
394
414
  case "if": {
395
415
  const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
396
416
  if (branch) {
@@ -472,10 +492,24 @@ export class YarnRunner {
472
492
  // resolveNode will handle node groups
473
493
  continue;
474
494
  }
475
- case "options": {
476
- this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }) => ({ text: o.text, tags: o.tags, css: o.css, markup: o.markup })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
477
- return;
478
- }
495
+ case "options": {
496
+ const available = this.filterOptions(ins.options);
497
+ if (available.length === 0) {
498
+ continue;
499
+ }
500
+ this.pendingOptions = available;
501
+ this.emit({
502
+ type: "options",
503
+ options: available.map((o) => {
504
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
505
+ return { text: interpolatedText, tags: o.tags, css: o.css, markup: interpolatedMarkup };
506
+ }),
507
+ nodeCss: resolved.css,
508
+ scene: resolved.scene,
509
+ isDialogueEnd: this.lookaheadIsEnd(),
510
+ });
511
+ return;
512
+ }
479
513
  case "if": {
480
514
  const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
481
515
  if (branch) {
@@ -531,11 +565,24 @@ export class YarnRunner {
531
565
  this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
532
566
  restore();
533
567
  return;
534
- case "options":
535
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, markup: o.markup })), isDialogueEnd: false });
536
- // Maintain context that options belong to main node at ip-1
537
- restore();
538
- return;
568
+ case "options": {
569
+ const available = this.filterOptions(ins.options);
570
+ if (available.length === 0) {
571
+ continue;
572
+ }
573
+ this.pendingOptions = available;
574
+ this.emit({
575
+ type: "options",
576
+ options: available.map((o) => {
577
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
578
+ return { text: interpolatedText, markup: interpolatedMarkup };
579
+ }),
580
+ isDialogueEnd: false,
581
+ });
582
+ // Maintain context that options belong to main node at ip-1
583
+ restore();
584
+ return;
585
+ }
539
586
  case "if": {
540
587
  const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
541
588
  if (branch) {
@@ -572,11 +619,29 @@ export class YarnRunner {
572
619
  }
573
620
  }
574
621
  }
575
- // Block produced no output; resume
576
- restore();
577
- this.step();
578
- }
579
-
622
+ // Block produced no output; resume
623
+ restore();
624
+ this.step();
625
+ }
626
+
627
+ private filterOptions(options: CompiledOption[]): CompiledOption[] {
628
+ const available: CompiledOption[] = [];
629
+ for (const option of options) {
630
+ if (!option.condition) {
631
+ available.push(option);
632
+ continue;
633
+ }
634
+ try {
635
+ if (this.evaluator.evaluate(option.condition)) {
636
+ available.push(option);
637
+ }
638
+ } catch {
639
+ // Treat errors as false conditions
640
+ }
641
+ }
642
+ return available;
643
+ }
644
+
580
645
  private lookaheadIsEnd(): boolean {
581
646
  // Check if current node has more emit-worthy instructions
582
647
  const node = this.resolveNode(this.nodeTitle);
@@ -0,0 +1,26 @@
1
+ import { test } from "node:test";
2
+ import { ok } from "node:assert";
3
+ import React from "react";
4
+ import { renderToStaticMarkup } from "react-dom/server";
5
+ import { parseYarn } from "../parse/parser.js";
6
+ import { compile } from "../compile/compiler.js";
7
+ import { DialogueView } from "../react/DialogueView.js";
8
+
9
+ test("DialogueView renders initial variables provided via props", () => {
10
+ const yarn = `
11
+ title: Start
12
+ ---
13
+ Narrator: Hello {$playerName}!
14
+ ===`;
15
+
16
+ const program = compile(parseYarn(yarn));
17
+
18
+ const html = renderToStaticMarkup(
19
+ <DialogueView program={program} startNode="Start" variables={{ playerName: "V" }} />
20
+ );
21
+
22
+ ok(
23
+ html.includes("Hello V"),
24
+ "Expected rendered dialogue to include the interpolated variable value from props"
25
+ );
26
+ });
@@ -43,6 +43,23 @@ test("parseMarkup handles self-closing tags", () => {
43
43
  );
44
44
  });
45
45
 
46
+ test("parseMarkup handles br line breaks", () => {
47
+ const result = parseMarkup("Line one[br]Line two[/br][br/]Line three");
48
+ strictEqual(result.text, "Line oneLine twoLine three");
49
+
50
+ const brSegments = result.segments.filter(
51
+ (segment) => segment.selfClosing && segment.wrappers.some((wrapper) => wrapper.name === "br")
52
+ );
53
+
54
+ strictEqual(brSegments.length, 2);
55
+ ok(
56
+ brSegments.every((segment) =>
57
+ segment.wrappers.some((wrapper) => wrapper.name === "br" && wrapper.type === "default")
58
+ ),
59
+ "Expected br wrappers to use default HTML type"
60
+ );
61
+ });
62
+
46
63
  test("parseMarkup respects nomarkup blocks and escaping", () => {
47
64
  const result = parseMarkup(`[nomarkup][b] raw [/b][/nomarkup] and \\[escaped\\]`);
48
65
  strictEqual(result.text, "[b] raw [/b] and [escaped]");
@@ -59,4 +76,3 @@ function findSegment(result: MarkupParseResult, target: string) {
59
76
  return text === target;
60
77
  });
61
78
  }
62
-