yarn-spinner-runner-ts 0.1.4-c → 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 (38) 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 +0 -3
  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 +4 -3
  13. package/dist/react/DialogueExample.js.map +1 -1
  14. package/dist/runtime/evaluator.d.ts +3 -0
  15. package/dist/runtime/evaluator.js +165 -3
  16. package/dist/runtime/evaluator.js.map +1 -1
  17. package/dist/runtime/runner.d.ts +2 -0
  18. package/dist/runtime/runner.js +66 -10
  19. package/dist/runtime/runner.js.map +1 -1
  20. package/dist/tests/markup.test.js +1 -1
  21. package/dist/tests/markup.test.js.map +1 -1
  22. package/dist/tests/options.test.js +164 -9
  23. package/dist/tests/options.test.js.map +1 -1
  24. package/dist/tests/variables_flow_cmds.test.js +117 -10
  25. package/dist/tests/variables_flow_cmds.test.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/compile/compiler.ts +2 -2
  28. package/src/compile/ir.ts +1 -1
  29. package/src/markup/parser.ts +0 -3
  30. package/src/model/ast.ts +1 -0
  31. package/src/parse/lexer.ts +18 -18
  32. package/src/parse/parser.ts +33 -22
  33. package/src/react/DialogueExample.tsx +11 -10
  34. package/src/runtime/evaluator.ts +224 -47
  35. package/src/runtime/runner.ts +102 -37
  36. package/src/tests/markup.test.ts +1 -1
  37. package/src/tests/options.test.ts +206 -36
  38. package/src/tests/variables_flow_cmds.test.ts +139 -28
@@ -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);
@@ -45,7 +45,7 @@ test("parseMarkup handles self-closing tags", () => {
45
45
 
46
46
  test("parseMarkup handles br line breaks", () => {
47
47
  const result = parseMarkup("Line one[br]Line two[/br][br/]Line three");
48
- strictEqual(result.text, "Line one\nLine two\nLine three");
48
+ strictEqual(result.text, "Line oneLine twoLine three");
49
49
 
50
50
  const brSegments = result.segments.filter(
51
51
  (segment) => segment.selfClosing && segment.wrappers.some((wrapper) => wrapper.name === "br")
@@ -34,39 +34,209 @@ Narrator: Choose one
34
34
 
35
35
 
36
36
 
37
- test("option markup is exposed", () => {
38
- const script = `
39
- title: Start
40
- ---
41
- Narrator: Choose
42
- -> [b]Bold[/b]
43
- Narrator: Bold
44
- -> [wave intensity=5]Custom[/wave]
45
- Narrator: Custom
46
- ===
47
- `;
48
-
49
- const doc = parseYarn(script);
50
- const ir = compile(doc);
51
- const runner = new YarnRunner(ir, { startAt: "Start" });
52
-
53
- runner.advance(); // move to options
54
- const result = runner.currentResult;
55
- ok(result && result.type === "options", "Expected options result");
56
- const options = result!.options;
57
- ok(options[0].markup, "Expected markup on first option");
58
- ok(options[1].markup, "Expected markup on second option");
59
- const boldMarkup = options[0].markup!;
60
- strictEqual(boldMarkup.text, "Bold");
61
- ok(
62
- boldMarkup.segments.some((segment) =>
63
- segment.wrappers.some((wrapper) => wrapper.name === "b" && wrapper.type === "default")
64
- ),
65
- "Expected bold wrapper"
66
- );
67
- const customWrapper = options[1].markup!.segments
68
- .flatMap((segment) => segment.wrappers)
69
- .find((wrapper) => wrapper.name === "wave");
70
- ok(customWrapper, "Expected custom wrapper on second option");
71
- strictEqual(customWrapper!.properties.intensity, 5);
72
- });
37
+ test("option markup is exposed", () => {
38
+ const script = `
39
+ title: Start
40
+ ---
41
+ Narrator: Choose
42
+ -> [b]Bold[/b]
43
+ Narrator: Bold
44
+ -> [wave intensity=5]Custom[/wave]
45
+ Narrator: Custom
46
+ ===
47
+ `;
48
+
49
+ const doc = parseYarn(script);
50
+ const ir = compile(doc);
51
+ const runner = new YarnRunner(ir, { startAt: "Start" });
52
+
53
+ runner.advance(); // move to options
54
+ const result = runner.currentResult;
55
+ ok(result && result.type === "options", "Expected options result");
56
+ const options = result!.options;
57
+ ok(options[0].markup, "Expected markup on first option");
58
+ ok(options[1].markup, "Expected markup on second option");
59
+ const boldMarkup = options[0].markup!;
60
+ strictEqual(boldMarkup.text, "Bold");
61
+ ok(
62
+ boldMarkup.segments.some((segment) =>
63
+ segment.wrappers.some((wrapper) => wrapper.name === "b" && wrapper.type === "default")
64
+ ),
65
+ "Expected bold wrapper"
66
+ );
67
+ const customWrapper = options[1].markup!.segments
68
+ .flatMap((segment) => segment.wrappers)
69
+ .find((wrapper) => wrapper.name === "wave");
70
+ ok(customWrapper, "Expected custom wrapper on second option");
71
+ strictEqual(customWrapper!.properties.intensity, 5);
72
+ });
73
+
74
+ test("option text interpolates variables", () => {
75
+ const script = `
76
+ title: Start
77
+ ---
78
+ <<set $cost to 150>>
79
+ <<set $bribe to 300>>
80
+ Narrator: Decide
81
+ -> Pay {$cost}
82
+ Narrator: Paid
83
+ -> Haggle {$bribe}
84
+ Narrator: Haggle
85
+ ===
86
+ `;
87
+
88
+ const doc = parseYarn(script);
89
+ const ir = compile(doc);
90
+ const runner = new YarnRunner(ir, { startAt: "Start" });
91
+
92
+ // First result is command for set
93
+ const initial = runner.currentResult;
94
+ strictEqual(initial?.type, "command", "Expected first <<set>> to emit a command result");
95
+ runner.advance(); // second <<set>> command
96
+ runner.advance(); // move to narration
97
+ runner.advance(); // move to options
98
+
99
+ const result = runner.currentResult;
100
+ if (!result || result.type !== "options") {
101
+ throw new Error("Expected to land on options");
102
+ }
103
+ const [pay, haggle] = result.options;
104
+ strictEqual(pay.text, "Pay 150", "Should replace placeholder with variable value");
105
+ strictEqual(haggle.text, "Haggle 300", "Should evaluate expressions inside placeholders");
106
+ });
107
+
108
+ test("conditional options respect once blocks and if statements", () => {
109
+ const script = `
110
+ title: Start
111
+ ---
112
+ <<declare $secret = false>>
113
+ Narrator: Boot
114
+ <<once>>
115
+ <<set $secret = true>>
116
+ <<endonce>>
117
+ Narrator: Menu
118
+ <<if $secret>>
119
+ -> Secret Option
120
+ Narrator: Secret taken
121
+ <<set $secret = false>>
122
+ <<jump Start>>
123
+ <<endif>>
124
+ -> Regular Option
125
+ Narrator: Regular taken
126
+ <<jump Start>>
127
+ ===
128
+ `;
129
+
130
+ const doc = parseYarn(script);
131
+ const ir = compile(doc);
132
+ const runner = new YarnRunner(ir, { startAt: "Start" });
133
+
134
+ const nextOptions = () => {
135
+ let guard = 25;
136
+ while (guard-- > 0) {
137
+ const result = runner.currentResult;
138
+ if (!result) throw new Error("Expected runtime result");
139
+ if (result.type === "options") {
140
+ return result;
141
+ }
142
+ runner.advance();
143
+ }
144
+ throw new Error("Failed to reach options result");
145
+ };
146
+
147
+ const secretMenu = nextOptions();
148
+ strictEqual(secretMenu.options.length, 1, "First pass should expose the conditional secret option");
149
+ strictEqual(secretMenu.options[0].text, "Secret Option");
150
+
151
+ // Consume the secret option to flip the flag off
152
+ runner.advance(0);
153
+
154
+ const fallbackMenu = nextOptions();
155
+ strictEqual(fallbackMenu.options.length, 1, "After the secret path is used, only the regular option should remain");
156
+ strictEqual(fallbackMenu.options[0].text, "Regular Option");
157
+ });
158
+
159
+ test("options allow space-indented bodies", () => {
160
+ const script = `
161
+ title: Start
162
+ ---
163
+ -> Pay
164
+ <<jump Pay>>
165
+ -> Run
166
+ <<jump Run>>
167
+ ===
168
+
169
+ title: Pay
170
+ ---
171
+ Narrator: Pay branch
172
+ ===
173
+
174
+ title: Run
175
+ ---
176
+ Narrator: Run branch
177
+ ===
178
+ `;
179
+
180
+ const doc = parseYarn(script);
181
+ const ir = compile(doc);
182
+ const runner = new YarnRunner(ir, { startAt: "Start" });
183
+
184
+ const initial = runner.currentResult;
185
+ if (initial?.type !== "options") {
186
+ runner.advance();
187
+ }
188
+ const optionsResult = runner.currentResult;
189
+ strictEqual(optionsResult?.type, "options", "Expected to reach options");
190
+ if (optionsResult?.type !== "options") throw new Error("Options not emitted");
191
+ strictEqual(optionsResult.options.length, 2, "Space indents should still group options together");
192
+ strictEqual(optionsResult.options[0].text, "Pay");
193
+ strictEqual(optionsResult.options[1].text, "Run");
194
+ });
195
+
196
+ test("inline [if] option condition filters options", () => {
197
+ const script = `
198
+ title: StartFalse
199
+ ---
200
+ <<declare $flag = false>>
201
+ -> Hidden [if $flag]
202
+ Narrator: Hidden
203
+ -> Visible
204
+ Narrator: Visible
205
+ ===
206
+
207
+ title: StartTrue
208
+ ---
209
+ <<declare $flag = true>>
210
+ -> Hidden [if $flag]
211
+ Narrator: Hidden
212
+ -> Visible
213
+ Narrator: Visible
214
+ ===
215
+ `;
216
+
217
+ const doc = parseYarn(script);
218
+ const ir = compile(doc);
219
+
220
+ const getOptions = (startNode: string) => {
221
+ const runner = new YarnRunner(ir, { startAt: startNode });
222
+ let guard = 25;
223
+ while (guard-- > 0) {
224
+ const result = runner.currentResult;
225
+ if (!result) break;
226
+ if (result.type === "options") {
227
+ return { runner, options: result };
228
+ }
229
+ runner.advance();
230
+ }
231
+ throw new Error("Failed to reach options");
232
+ };
233
+
234
+ const { options: optionsFalse } = getOptions("StartFalse");
235
+ strictEqual(optionsFalse.options.length, 1, "Hidden option should be filtered out when condition is false");
236
+ strictEqual(optionsFalse.options[0].text, "Visible");
237
+
238
+ const { options: optionsTrue } = getOptions("StartTrue");
239
+ strictEqual(optionsTrue.options.length, 2, "Both options should appear when condition is true");
240
+ strictEqual(optionsTrue.options[0].text, "Hidden");
241
+ strictEqual(optionsTrue.options[1].text, "Visible");
242
+ });
@@ -2,32 +2,143 @@ import { test } from "node:test";
2
2
  import { strictEqual } from "node:assert";
3
3
  import { parseYarn, compile, YarnRunner } from "../index.js";
4
4
 
5
- test("variables, flow control, and commands", () => {
6
- const script = `
7
- title: Start
8
- ---
9
- <<set $score to 10>>
10
- <<if $score >= 10>>
11
- Narrator: High
12
- <<else>>
13
- Narrator: Low
14
- <<endif>>
15
- ===
16
- `;
17
-
18
- const doc = parseYarn(script);
19
- const ir = compile(doc);
20
- const runner = new YarnRunner(ir, { startAt: "Start" });
21
-
22
- // After command, expect if-branch 'High'
23
- // First result should be command emission
24
- const a = runner.currentResult!;
25
- strictEqual(a.type, "command", "First result should be command");
26
- runner.advance();
27
- const b = runner.currentResult!;
28
- strictEqual(b.type, "text", "Should be text after command");
29
- if (b.type === "text") strictEqual(/High/.test(b.text), true, "Expected High branch");
30
- strictEqual(runner.getVariable("score"), 10, "Variable should be set");
31
- });
32
-
5
+ test("variables, flow control, and commands", () => {
6
+ const script = `
7
+ title: Start
8
+ ---
9
+ <<set $score to 10>>
10
+ <<if $score >= 10>>
11
+ Narrator: High
12
+ <<else>>
13
+ Narrator: Low
14
+ <<endif>>
15
+ ===
16
+ `;
17
+
18
+ const doc = parseYarn(script);
19
+ const ir = compile(doc);
20
+ const runner = new YarnRunner(ir, { startAt: "Start" });
21
+
22
+ // After command, expect if-branch 'High'
23
+ // First result should be command emission
24
+ const a = runner.currentResult!;
25
+ strictEqual(a.type, "command", "First result should be command");
26
+ runner.advance();
27
+ const b = runner.currentResult!;
28
+ strictEqual(b.type, "text", "Should be text after command");
29
+ if (b.type === "text") strictEqual(/High/.test(b.text), true, "Expected High branch");
30
+ strictEqual(runner.getVariable("score"), 10, "Variable should be set");
31
+ });
32
+
33
+ test("equality operators support ==, !=, and single =", () => {
34
+ const script = `
35
+ title: Start
36
+ ---
37
+ <<set $doorOpen to true>>
38
+ <<if $doorOpen = true>>
39
+ Narrator: Single equals ok
40
+ <<endif>>
41
+ <<if $doorOpen == true>>
42
+ Narrator: Double equals ok
43
+ <<endif>>
44
+ <<if $doorOpen != false>>
45
+ Narrator: Not equals ok
46
+ <<endif>>
47
+ ===
48
+ `;
49
+
50
+ const doc = parseYarn(script);
51
+ const ir = compile(doc);
52
+ const runner = new YarnRunner(ir, { startAt: "Start" });
53
+
54
+ const seen: string[] = [];
55
+ let guard = 25;
56
+ while (guard-- > 0) {
57
+ const result = runner.currentResult;
58
+ if (!result) break;
59
+ if (result.type === "text" && result.text.trim()) {
60
+ seen.push(result.text.trim());
61
+ }
62
+ if (result.isDialogueEnd) {
63
+ break;
64
+ }
65
+ if (result.type === "options") {
66
+ runner.advance(0);
67
+ } else {
68
+ runner.advance();
69
+ }
70
+ }
71
+
72
+ strictEqual(seen.includes("Single equals ok"), true, "Single equals comparison should succeed");
73
+ strictEqual(seen.includes("Double equals ok"), true, "Double equals comparison should succeed");
74
+ strictEqual(seen.includes("Not equals ok"), true, "Not equals comparison should succeed");
75
+ });
76
+
77
+ test("set command supports equals syntax with arithmetic reassignment", () => {
78
+ const script = `
79
+ title: StreetCred
80
+ ---
81
+ <<set $reputation = 100>>
82
+ <<set $reputation = $reputation - 25 >>
83
+ Narrator: Current street cred: {$reputation}
84
+ ===
85
+ `;
86
+
87
+ const doc = parseYarn(script);
88
+ const ir = compile(doc);
89
+ const runner = new YarnRunner(ir, { startAt: "StreetCred" });
90
+
91
+ const seen: string[] = [];
92
+ for (let guard = 0; guard < 20; guard++) {
93
+ const result = runner.currentResult;
94
+ if (!result) break;
95
+ if (result.type === "text" && result.text.trim()) {
96
+ seen.push(result.text.trim());
97
+ }
98
+ if (result.isDialogueEnd) break;
99
+ if (result.type === "options") {
100
+ runner.advance(0);
101
+ } else {
102
+ runner.advance();
103
+ }
104
+ }
105
+
106
+ strictEqual(seen.includes("Current street cred: 75"), true, "Should reflect arithmetic subtraction");
107
+ strictEqual(runner.getVariable("reputation"), 75, "Variable should store updated numeric value");
108
+ });
109
+
110
+ test("set command respects arithmetic precedence and parentheses", () => {
111
+ const script = `
112
+ title: MathChecks
113
+ ---
114
+ <<set $score = 10>>
115
+ <<set $score = $score + 10 * 2>>
116
+ <<set $score = ($score + 10) / 2>>
117
+ Narrator: Score now {$score}
118
+ ===
119
+ `;
120
+
121
+ const doc = parseYarn(script);
122
+ const ir = compile(doc);
123
+ const runner = new YarnRunner(ir, { startAt: "MathChecks" });
124
+
125
+ const lines: string[] = [];
126
+ for (let guard = 0; guard < 20; guard++) {
127
+ const result = runner.currentResult;
128
+ if (!result) break;
129
+ if (result.type === "text" && result.text.trim()) {
130
+ lines.push(result.text.trim());
131
+ }
132
+ if (result.isDialogueEnd) break;
133
+ if (result.type === "options") {
134
+ runner.advance(0);
135
+ } else {
136
+ runner.advance();
137
+ }
138
+ }
139
+
140
+ strictEqual(lines.includes("Score now 20"), true, "Should honor operator precedence and parentheses");
141
+ strictEqual(runner.getVariable("score"), 20, "Final numeric value should be 20");
142
+ });
143
+
33
144