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
@@ -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