yarn-spinner-runner-ts 0.1.4 → 0.1.5
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.
- package/README.md +34 -16
- package/dist/compile/compiler.js +2 -2
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +1 -0
- package/dist/markup/parser.js +12 -2
- package/dist/markup/parser.js.map +1 -1
- package/dist/model/ast.d.ts +1 -0
- package/dist/parse/lexer.js +5 -5
- package/dist/parse/lexer.js.map +1 -1
- package/dist/parse/parser.js +12 -2
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +12 -10
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueView.d.ts +10 -4
- package/dist/react/DialogueView.js +38 -12
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.js +1 -1
- package/dist/react/MarkupRenderer.js.map +1 -1
- package/dist/react/useYarnRunner.js +63 -10
- package/dist/react/useYarnRunner.js.map +1 -1
- package/dist/runtime/evaluator.js +3 -2
- package/dist/runtime/evaluator.js.map +1 -1
- package/dist/runtime/runner.d.ts +2 -0
- package/dist/runtime/runner.js +66 -10
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/dialogue_view.test.d.ts +1 -0
- package/dist/tests/dialogue_view.test.js +18 -0
- package/dist/tests/dialogue_view.test.js.map +1 -0
- package/dist/tests/markup.test.js +7 -0
- package/dist/tests/markup.test.js.map +1 -1
- package/dist/tests/options.test.js +164 -9
- package/dist/tests/options.test.js.map +1 -1
- package/dist/tests/variables_flow_cmds.test.js +52 -10
- package/dist/tests/variables_flow_cmds.test.js.map +1 -1
- package/docs/markup.md +33 -33
- package/eslint.config.cjs +39 -39
- package/package.json +6 -6
- package/src/compile/compiler.ts +2 -2
- package/src/compile/ir.ts +1 -1
- package/src/markup/parser.ts +53 -43
- package/src/model/ast.ts +1 -0
- package/src/parse/lexer.ts +18 -18
- package/src/parse/parser.ts +33 -22
- package/src/react/DialogueExample.tsx +16 -14
- package/src/react/DialogueView.tsx +312 -275
- package/src/react/MarkupRenderer.tsx +1 -2
- package/src/react/useYarnRunner.tsx +101 -34
- package/src/runtime/evaluator.ts +15 -14
- package/src/runtime/runner.ts +102 -37
- package/src/tests/dialogue_view.test.tsx +26 -0
- package/src/tests/markup.test.ts +17 -1
- package/src/tests/options.test.ts +206 -36
- package/src/tests/variables_flow_cmds.test.ts +72 -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
): {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
package/src/runtime/evaluator.ts
CHANGED
|
@@ -110,20 +110,21 @@ export class ExpressionEvaluator {
|
|
|
110
110
|
|
|
111
111
|
private evaluateComparison(expr: string): boolean {
|
|
112
112
|
// Match comparison operators (avoid matching !=, <=, >=)
|
|
113
|
-
const match = expr.match(/^(.+?)\s*(
|
|
114
|
-
if (!match) throw new Error(`Invalid comparison: ${expr}`);
|
|
115
|
-
|
|
116
|
-
const [, left,
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
case "
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
case "
|
|
126
|
-
|
|
113
|
+
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
|
|
114
|
+
if (!match) throw new Error(`Invalid comparison: ${expr}`);
|
|
115
|
+
|
|
116
|
+
const [, left, rawOp, right] = match;
|
|
117
|
+
const op = rawOp === "=" ? "==" : rawOp;
|
|
118
|
+
const leftVal = this.evaluateExpression(left.trim());
|
|
119
|
+
const rightVal = this.evaluateExpression(right.trim());
|
|
120
|
+
|
|
121
|
+
switch (op) {
|
|
122
|
+
case "===":
|
|
123
|
+
case "==":
|
|
124
|
+
return this.deepEquals(leftVal, rightVal);
|
|
125
|
+
case "!==":
|
|
126
|
+
case "!=":
|
|
127
|
+
return !this.deepEquals(leftVal, rightVal);
|
|
127
128
|
case "<":
|
|
128
129
|
return Number(leftVal) < Number(rightVal);
|
|
129
130
|
case ">":
|
package/src/runtime/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
392
|
-
|
|
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.
|
|
477
|
-
|
|
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.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
});
|
package/src/tests/markup.test.ts
CHANGED
|
@@ -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
|
-
|
|
@@ -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
|
+
});
|