yarn-spinner-runner-ts 0.1.3 → 0.1.4-b
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 +5 -0
- package/dist/markup/parser.js +15 -2
- package/dist/markup/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +9 -8
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueView.d.ts +9 -4
- package/dist/react/DialogueView.js +22 -8
- 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 +10 -1
- package/dist/react/useYarnRunner.js.map +1 -1
- package/dist/runtime/commands.js +12 -1
- package/dist/runtime/commands.js.map +1 -1
- package/dist/runtime/runner.d.ts +6 -0
- package/dist/runtime/runner.js +10 -0
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/custom_functions.test.d.ts +1 -0
- package/dist/tests/custom_functions.test.js +129 -0
- package/dist/tests/custom_functions.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/story_end.test.d.ts +1 -0
- package/dist/tests/story_end.test.js +37 -0
- package/dist/tests/story_end.test.js.map +1 -0
- package/docs/markup.md +33 -33
- package/eslint.config.cjs +39 -39
- package/package.json +1 -1
- package/src/markup/parser.ts +56 -43
- package/src/react/DialogueExample.tsx +12 -13
- package/src/react/DialogueView.tsx +298 -275
- package/src/react/MarkupRenderer.tsx +1 -2
- package/src/react/useYarnRunner.tsx +13 -1
- package/src/runtime/commands.ts +14 -1
- package/src/runtime/runner.ts +12 -0
- package/src/tests/custom_functions.test.ts +140 -0
- package/src/tests/markup.test.ts +17 -1
- package/src/tests/story_end.test.ts +42 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
|
|
4
|
+
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
|
|
5
5
|
|
|
6
6
|
interface RenderPiece {
|
|
7
7
|
text: string;
|
|
@@ -107,4 +107,3 @@ function createWrapperElement(
|
|
|
107
107
|
function sanitizeClassName(name: string): string {
|
|
108
108
|
return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
109
109
|
}
|
|
110
|
-
|
|
@@ -13,11 +13,23 @@ export function useYarnRunner(
|
|
|
13
13
|
} {
|
|
14
14
|
const runnerRef = useRef<YarnRunner | null>(null);
|
|
15
15
|
const [result, setResult] = useState<RuntimeResult | null>(null);
|
|
16
|
+
const optionsRef = useRef(options);
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
+
// Update runner if functions change
|
|
19
|
+
if (
|
|
20
|
+
runnerRef.current &&
|
|
21
|
+
JSON.stringify(optionsRef.current.functions) !== JSON.stringify(options.functions)
|
|
22
|
+
) {
|
|
23
|
+
runnerRef.current = new YarnRunner(program, options);
|
|
24
|
+
setResult(runnerRef.current.currentResult);
|
|
25
|
+
optionsRef.current = options;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Initialize runner if not exists
|
|
18
29
|
if (!runnerRef.current) {
|
|
19
30
|
runnerRef.current = new YarnRunner(program, options);
|
|
20
31
|
setResult(runnerRef.current.currentResult);
|
|
32
|
+
optionsRef.current = options;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
const runner = runnerRef.current;
|
package/src/runtime/commands.ts
CHANGED
|
@@ -29,6 +29,14 @@ export function parseCommand(content: string): ParsedCommand {
|
|
|
29
29
|
const char = trimmed[i];
|
|
30
30
|
|
|
31
31
|
if ((char === '"' || char === "'") && !inQuotes) {
|
|
32
|
+
// If we have accumulated non-quoted content (e.g. a function name and "(")
|
|
33
|
+
// push it as its own part before entering quoted mode. This prevents the
|
|
34
|
+
// surrounding text from being merged into the quoted content when we
|
|
35
|
+
// later push the quoted value.
|
|
36
|
+
if (current.trim()) {
|
|
37
|
+
parts.push(current.trim());
|
|
38
|
+
current = "";
|
|
39
|
+
}
|
|
32
40
|
inQuotes = true;
|
|
33
41
|
quoteChar = char;
|
|
34
42
|
continue;
|
|
@@ -36,8 +44,11 @@ export function parseCommand(content: string): ParsedCommand {
|
|
|
36
44
|
|
|
37
45
|
if (char === quoteChar && inQuotes) {
|
|
38
46
|
inQuotes = false;
|
|
47
|
+
// Preserve the surrounding quotes in the parsed part so callers that
|
|
48
|
+
// reassemble the expression (e.g. declare handlers) keep string literals
|
|
49
|
+
// intact instead of losing quote characters.
|
|
50
|
+
parts.push(quoteChar + current + quoteChar);
|
|
39
51
|
quoteChar = "";
|
|
40
|
-
parts.push(current);
|
|
41
52
|
current = "";
|
|
42
53
|
continue;
|
|
43
54
|
}
|
|
@@ -129,11 +140,13 @@ export class CommandHandler {
|
|
|
129
140
|
this.register("declare", (args, evaluator) => {
|
|
130
141
|
if (!evaluator) return;
|
|
131
142
|
if (args.length < 3) return; // name, '=', expr
|
|
143
|
+
|
|
132
144
|
const varNameRaw = args[0];
|
|
133
145
|
let exprParts = args.slice(1);
|
|
134
146
|
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
|
|
135
147
|
const expr = exprParts.join(" ");
|
|
136
148
|
|
|
149
|
+
|
|
137
150
|
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
|
|
138
151
|
|
|
139
152
|
// Check if expression is "smart" (contains operators, comparisons, or variable references)
|
package/src/runtime/runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface RunnerOptions {
|
|
|
10
10
|
functions?: Record<string, (...args: unknown[]) => unknown>;
|
|
11
11
|
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
|
12
12
|
commandHandler?: CommandHandler;
|
|
13
|
+
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const globalOnceSeen = new Set<string>();
|
|
@@ -23,6 +24,8 @@ export class YarnRunner {
|
|
|
23
24
|
private readonly commandHandler: CommandHandler;
|
|
24
25
|
private readonly evaluator: ExpressionEvaluator;
|
|
25
26
|
private readonly onceSeen = globalOnceSeen;
|
|
27
|
+
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
|
28
|
+
private storyEnded = false;
|
|
26
29
|
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
|
27
30
|
private readonly visitCounts: Record<string, number> = {};
|
|
28
31
|
|
|
@@ -95,6 +98,7 @@ export class YarnRunner {
|
|
|
95
98
|
...(opts.functions ?? {}),
|
|
96
99
|
} as Record<string, (...args: unknown[]) => unknown>;
|
|
97
100
|
this.handleCommand = opts.handleCommand;
|
|
101
|
+
this.onStoryEnd = opts.onStoryEnd;
|
|
98
102
|
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
|
|
99
103
|
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
|
100
104
|
this.nodeTitle = opts.startAt;
|
|
@@ -589,6 +593,14 @@ export class YarnRunner {
|
|
|
589
593
|
private emit(res: RuntimeResult) {
|
|
590
594
|
this.currentResult = res;
|
|
591
595
|
this.history.push(res);
|
|
596
|
+
if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
|
|
597
|
+
this.storyEnded = true;
|
|
598
|
+
if (this.onStoryEnd) {
|
|
599
|
+
// Create a readonly copy of the variables
|
|
600
|
+
const variablesCopy = Object.freeze({ ...this.variables });
|
|
601
|
+
this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
592
604
|
// If we ended a detour node, return to caller after emitting last result
|
|
593
605
|
// Position is restored here, but we wait for next advance() to continue
|
|
594
606
|
if (res.isDialogueEnd && this.callStack.length > 0) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { strictEqual, ok, match } from "node:assert";
|
|
3
|
+
import { parseYarn, compile } from "../index.js";
|
|
4
|
+
import { YarnRunner } from "../runtime/runner.js";
|
|
5
|
+
|
|
6
|
+
test("custom functions", () => {
|
|
7
|
+
const yarnText = `
|
|
8
|
+
title: CustomFuncs
|
|
9
|
+
---
|
|
10
|
+
<<declare $doubled = multiply(2, 3)>>
|
|
11
|
+
<<declare $concatenated = concat("Hello", " World")>>
|
|
12
|
+
<<declare $power = pow(2, 3)>>
|
|
13
|
+
<<declare $conditionalValue = ifThen(true, "yes", "no")>>
|
|
14
|
+
Result: {$doubled}, {$concatenated}, {$power}, {$conditionalValue}
|
|
15
|
+
===
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const ast = parseYarn(yarnText);
|
|
19
|
+
const program = compile(ast);
|
|
20
|
+
const runner = new YarnRunner(program, {
|
|
21
|
+
startAt: "CustomFuncs",
|
|
22
|
+
functions: {
|
|
23
|
+
multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
|
|
24
|
+
concat: (a: unknown, b: unknown) => String(a) + String(b),
|
|
25
|
+
pow: (base: unknown, exp: unknown) => Math.pow(Number(base), Number(exp)),
|
|
26
|
+
ifThen: (cond: unknown, yes: unknown, no: unknown) => Boolean(cond) ? yes : no,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Need to advance past declare commands and get to the text line
|
|
31
|
+
for (let i = 0; i < 4; i++) {
|
|
32
|
+
runner.advance();
|
|
33
|
+
}
|
|
34
|
+
strictEqual(runner.currentResult?.type, "text");
|
|
35
|
+
if (runner.currentResult?.type === "text") {
|
|
36
|
+
const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
|
|
37
|
+
strictEqual(fullText, "Result: 6, Hello World, 8, yes");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("custom functions with type coercion", () => {
|
|
42
|
+
const yarnText = `
|
|
43
|
+
title: TypeCoercion
|
|
44
|
+
---
|
|
45
|
+
<<declare $numFromStr = multiply("2", "3")>>
|
|
46
|
+
<<declare $concatNums = concat(123, 456)>>
|
|
47
|
+
<<declare $boolStr = ifThen("true", 1, 0)>>
|
|
48
|
+
Result: {$numFromStr}, {$concatNums}, {$boolStr}
|
|
49
|
+
===
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const ast = parseYarn(yarnText);
|
|
53
|
+
const program = compile(ast);
|
|
54
|
+
const runner = new YarnRunner(program, {
|
|
55
|
+
startAt: "TypeCoercion",
|
|
56
|
+
functions: {
|
|
57
|
+
multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
|
|
58
|
+
concat: (a: unknown, b: unknown) => String(a) + String(b),
|
|
59
|
+
ifThen: (cond: unknown, yes: unknown, no: unknown) => Boolean(cond) ? yes : no,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Need to advance past declare commands and get to the text line
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
runner.advance();
|
|
66
|
+
}
|
|
67
|
+
strictEqual(runner.currentResult?.type, "text");
|
|
68
|
+
if (runner.currentResult?.type === "text") {
|
|
69
|
+
const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
|
|
70
|
+
strictEqual(fullText, "Result: 6, 123456, 1");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("custom functions error handling", () => {
|
|
75
|
+
const yarnText = `
|
|
76
|
+
title: ErrorHandling
|
|
77
|
+
---
|
|
78
|
+
<<declare $result = safeDivide(10, 0)>>
|
|
79
|
+
Result: {$result}
|
|
80
|
+
===
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const ast = parseYarn(yarnText);
|
|
84
|
+
const program = compile(ast);
|
|
85
|
+
const runner = new YarnRunner(program, {
|
|
86
|
+
startAt: "ErrorHandling",
|
|
87
|
+
functions: {
|
|
88
|
+
safeDivide: (a: unknown, b: unknown) => {
|
|
89
|
+
const numerator = Number(a);
|
|
90
|
+
const denominator = Number(b);
|
|
91
|
+
return denominator === 0 ? "Cannot divide by zero" : numerator / denominator;
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Advance until we reach a text result (some commands emit immediately)
|
|
97
|
+
for (let i = 0; i < 10 && runner.currentResult?.type !== "text"; i++) {
|
|
98
|
+
runner.advance();
|
|
99
|
+
}
|
|
100
|
+
strictEqual(runner.currentResult?.type, "text");
|
|
101
|
+
if (runner.currentResult?.type === "text") {
|
|
102
|
+
const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
|
|
103
|
+
strictEqual(fullText, "Result: Cannot divide by zero");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("custom functions alongside built-in functions", () => {
|
|
108
|
+
const yarnText = `
|
|
109
|
+
title: MixedFunctions
|
|
110
|
+
---
|
|
111
|
+
<<declare $random = random()>>
|
|
112
|
+
<<declare $doubled = multiply($random, 2)>>
|
|
113
|
+
<<declare $formatted = format_number($doubled)>>
|
|
114
|
+
Result: {$formatted}
|
|
115
|
+
===
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const ast = parseYarn(yarnText);
|
|
119
|
+
const program = compile(ast);
|
|
120
|
+
const runner = new YarnRunner(program, {
|
|
121
|
+
startAt: "MixedFunctions",
|
|
122
|
+
functions: {
|
|
123
|
+
multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
|
|
124
|
+
format_number: (n: unknown) => Number(n).toFixed(2),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Need to advance past declare commands and get to the text line
|
|
129
|
+
for (let i = 0; i < 3; i++) {
|
|
130
|
+
runner.advance();
|
|
131
|
+
}
|
|
132
|
+
strictEqual(runner.currentResult?.type, "text");
|
|
133
|
+
if (runner.currentResult?.type === "text") {
|
|
134
|
+
const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
|
|
135
|
+
const resultNumber = parseFloat(fullText.replace("Result: ", ""));
|
|
136
|
+
ok(resultNumber >= 0);
|
|
137
|
+
ok(resultNumber <= 2);
|
|
138
|
+
match(fullText, /Result: \d+\.\d{2}/);
|
|
139
|
+
}
|
|
140
|
+
});
|
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 one\nLine two\nLine 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
|
-
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { ok, strictEqual } from "node:assert";
|
|
3
|
+
import { parseYarn, compile, YarnRunner } from "../index.js";
|
|
4
|
+
|
|
5
|
+
test("onStoryEnd receives variables snapshot", () => {
|
|
6
|
+
const script = `
|
|
7
|
+
title: Start
|
|
8
|
+
---
|
|
9
|
+
Narrator: Beginning
|
|
10
|
+
<<set $score = 42>>
|
|
11
|
+
Narrator: Done
|
|
12
|
+
===
|
|
13
|
+
`;
|
|
14
|
+
let payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true } | undefined;
|
|
15
|
+
const doc = parseYarn(script);
|
|
16
|
+
const ir = compile(doc);
|
|
17
|
+
const runner = new YarnRunner(ir, {
|
|
18
|
+
startAt: "Start",
|
|
19
|
+
onStoryEnd: (info) => {
|
|
20
|
+
payload = info;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let result = runner.currentResult;
|
|
25
|
+
ok(result && result.type === "text");
|
|
26
|
+
|
|
27
|
+
runner.advance();
|
|
28
|
+
result = runner.currentResult;
|
|
29
|
+
ok(result && result.type === "command");
|
|
30
|
+
|
|
31
|
+
runner.advance();
|
|
32
|
+
result = runner.currentResult;
|
|
33
|
+
ok(result && result.type === "text");
|
|
34
|
+
|
|
35
|
+
runner.advance();
|
|
36
|
+
result = runner.currentResult;
|
|
37
|
+
ok(result && result.isDialogueEnd === true);
|
|
38
|
+
|
|
39
|
+
strictEqual(payload?.storyEnd, true);
|
|
40
|
+
const variables = payload?.variables ?? {};
|
|
41
|
+
strictEqual((variables as Record<string, unknown>)["score"], 42);
|
|
42
|
+
});
|