yarn-spinner-runner-ts 0.1.2 → 0.1.4
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 +102 -88
- package/dist/compile/compiler.js +4 -4
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markup/parser.d.ts +3 -0
- package/dist/markup/parser.js +332 -0
- package/dist/markup/parser.js.map +1 -0
- package/dist/markup/types.d.ts +17 -0
- package/dist/markup/types.js +2 -0
- package/dist/markup/types.js.map +1 -0
- package/dist/model/ast.d.ts +3 -0
- package/dist/parse/parser.js +57 -8
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +6 -4
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueScene.d.ts +2 -1
- package/dist/react/DialogueScene.js +95 -26
- package/dist/react/DialogueScene.js.map +1 -1
- package/dist/react/DialogueView.d.ts +10 -1
- package/dist/react/DialogueView.js +68 -5
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.d.ts +8 -0
- package/dist/react/MarkupRenderer.js +64 -0
- package/dist/react/MarkupRenderer.js.map +1 -0
- package/dist/react/TypingText.d.ts +14 -0
- package/dist/react/TypingText.js +78 -0
- package/dist/react/TypingText.js.map +1 -0
- package/dist/runtime/commands.js +12 -1
- package/dist/runtime/commands.js.map +1 -1
- package/dist/runtime/results.d.ts +3 -0
- package/dist/runtime/runner.d.ts +7 -0
- package/dist/runtime/runner.js +161 -14
- 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.d.ts +1 -0
- package/dist/tests/markup.test.js +46 -0
- package/dist/tests/markup.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.js +25 -1
- package/dist/tests/nodes_lines.test.js.map +1 -1
- package/dist/tests/options.test.js +30 -1
- package/dist/tests/options.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/dist/tests/typing-text.test.d.ts +1 -0
- package/dist/tests/typing-text.test.js +12 -0
- package/dist/tests/typing-text.test.js.map +1 -0
- package/docs/actor-transition.md +34 -0
- package/docs/markup.md +34 -19
- package/docs/scenes-actors-setup.md +1 -0
- package/docs/typing-animation.md +44 -0
- package/eslint.config.cjs +3 -0
- package/examples/browser/index.html +1 -1
- package/examples/browser/main.tsx +0 -2
- package/package.json +6 -6
- package/src/compile/compiler.ts +4 -4
- package/src/compile/ir.ts +3 -2
- package/src/index.ts +3 -0
- package/src/markup/parser.ts +372 -0
- package/src/markup/types.ts +22 -0
- package/src/model/ast.ts +17 -13
- package/src/parse/parser.ts +60 -8
- package/src/react/DialogueExample.tsx +18 -42
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +122 -8
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +127 -0
- package/src/react/dialogue.css +26 -13
- package/src/runtime/commands.ts +14 -1
- package/src/runtime/results.ts +3 -1
- package/src/runtime/runner.ts +170 -14
- package/src/tests/custom_functions.test.ts +140 -0
- package/src/tests/markup.test.ts +62 -0
- package/src/tests/nodes_lines.test.ts +35 -1
- package/src/tests/options.test.ts +39 -1
- package/src/tests/story_end.test.ts +42 -0
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/results.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { MarkupParseResult } from "../markup/types.js";
|
|
1
2
|
export type TextResult = {
|
|
2
3
|
type: "text";
|
|
3
4
|
text: string;
|
|
4
5
|
speaker?: string;
|
|
5
6
|
tags?: string[];
|
|
7
|
+
markup?: MarkupParseResult;
|
|
6
8
|
nodeCss?: string; // Node-level CSS from &css{} header
|
|
7
9
|
scene?: string; // Scene name from node header
|
|
8
10
|
isDialogueEnd: boolean;
|
|
@@ -10,7 +12,7 @@ export type TextResult = {
|
|
|
10
12
|
|
|
11
13
|
export type OptionsResult = {
|
|
12
14
|
type: "options";
|
|
13
|
-
options: { text: string; tags?: string[]; css?: string }[];
|
|
15
|
+
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
|
|
14
16
|
nodeCss?: string; // Node-level CSS from &css{} header
|
|
15
17
|
scene?: string; // Scene name from node header
|
|
16
18
|
isDialogueEnd: boolean;
|
package/src/runtime/runner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
|
|
2
|
+
import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types.js";
|
|
2
3
|
import type { RuntimeResult } from "./results.js";
|
|
3
4
|
import { ExpressionEvaluator } from "./evaluator.js";
|
|
4
5
|
import { CommandHandler, parseCommand } from "./commands.js";
|
|
@@ -9,6 +10,7 @@ export interface RunnerOptions {
|
|
|
9
10
|
functions?: Record<string, (...args: unknown[]) => unknown>;
|
|
10
11
|
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
|
11
12
|
commandHandler?: CommandHandler;
|
|
13
|
+
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const globalOnceSeen = new Set<string>();
|
|
@@ -22,6 +24,8 @@ export class YarnRunner {
|
|
|
22
24
|
private readonly commandHandler: CommandHandler;
|
|
23
25
|
private readonly evaluator: ExpressionEvaluator;
|
|
24
26
|
private readonly onceSeen = globalOnceSeen;
|
|
27
|
+
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
|
28
|
+
private storyEnded = false;
|
|
25
29
|
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
|
26
30
|
private readonly visitCounts: Record<string, number> = {};
|
|
27
31
|
|
|
@@ -94,6 +98,7 @@ export class YarnRunner {
|
|
|
94
98
|
...(opts.functions ?? {}),
|
|
95
99
|
} as Record<string, (...args: unknown[]) => unknown>;
|
|
96
100
|
this.handleCommand = opts.handleCommand;
|
|
101
|
+
this.onStoryEnd = opts.onStoryEnd;
|
|
97
102
|
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
|
|
98
103
|
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
|
99
104
|
this.nodeTitle = opts.startAt;
|
|
@@ -205,15 +210,152 @@ export class YarnRunner {
|
|
|
205
210
|
this.step();
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
private interpolate(text: string): string {
|
|
209
|
-
|
|
213
|
+
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
|
|
214
|
+
const evaluateExpression = (expr: string): string => {
|
|
210
215
|
try {
|
|
211
|
-
const
|
|
212
|
-
|
|
216
|
+
const value = this.evaluator.evaluateExpression(expr.trim());
|
|
217
|
+
if (value === null || value === undefined) {
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
return String(value);
|
|
213
221
|
} catch {
|
|
214
222
|
return "";
|
|
215
223
|
}
|
|
216
|
-
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!markup) {
|
|
227
|
+
const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
|
|
228
|
+
return { text: interpolated };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const segments = markup.segments.filter((segment) => !segment.selfClosing);
|
|
232
|
+
const getWrappersAt = (index: number): MarkupWrapper[] => {
|
|
233
|
+
for (const segment of segments) {
|
|
234
|
+
if (segment.start <= index && index < segment.end) {
|
|
235
|
+
return segment.wrappers.map((wrapper) => ({
|
|
236
|
+
name: wrapper.name,
|
|
237
|
+
type: wrapper.type,
|
|
238
|
+
properties: { ...wrapper.properties },
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (segments.length === 0) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
if (index > 0) {
|
|
246
|
+
return getWrappersAt(index - 1);
|
|
247
|
+
}
|
|
248
|
+
return segments[0].wrappers.map((wrapper) => ({
|
|
249
|
+
name: wrapper.name,
|
|
250
|
+
type: wrapper.type,
|
|
251
|
+
properties: { ...wrapper.properties },
|
|
252
|
+
}));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const resultChars: string[] = [];
|
|
256
|
+
const newSegments: MarkupSegment[] = [];
|
|
257
|
+
let currentSegment: MarkupSegment | null = null;
|
|
258
|
+
|
|
259
|
+
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
|
|
260
|
+
if (a.length !== b.length) return false;
|
|
261
|
+
for (let i = 0; i < a.length; i++) {
|
|
262
|
+
const wa = a[i];
|
|
263
|
+
const wb = b[i];
|
|
264
|
+
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
|
265
|
+
const keysA = Object.keys(wa.properties);
|
|
266
|
+
const keysB = Object.keys(wb.properties);
|
|
267
|
+
if (keysA.length !== keysB.length) return false;
|
|
268
|
+
for (const key of keysA) {
|
|
269
|
+
if (wa.properties[key] !== wb.properties[key]) return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const flushSegment = () => {
|
|
276
|
+
if (currentSegment) {
|
|
277
|
+
newSegments.push(currentSegment);
|
|
278
|
+
currentSegment = null;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
|
|
283
|
+
const index = resultChars.length;
|
|
284
|
+
resultChars.push(char);
|
|
285
|
+
const wrappersCopy = wrappers.map((wrapper) => ({
|
|
286
|
+
name: wrapper.name,
|
|
287
|
+
type: wrapper.type,
|
|
288
|
+
properties: { ...wrapper.properties },
|
|
289
|
+
}));
|
|
290
|
+
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
|
|
291
|
+
currentSegment.end = index + 1;
|
|
292
|
+
} else {
|
|
293
|
+
flushSegment();
|
|
294
|
+
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
|
|
299
|
+
if (!value) {
|
|
300
|
+
flushSegment();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const ch of value) {
|
|
304
|
+
appendCharWithWrappers(ch, wrappers);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
let i = 0;
|
|
309
|
+
while (i < text.length) {
|
|
310
|
+
const char = text[i];
|
|
311
|
+
if (char === '{') {
|
|
312
|
+
const close = text.indexOf('}', i + 1);
|
|
313
|
+
if (close === -1) {
|
|
314
|
+
appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
|
|
315
|
+
i += 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const expr = text.slice(i + 1, close);
|
|
319
|
+
const evaluated = evaluateExpression(expr);
|
|
320
|
+
const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
|
|
321
|
+
appendStringWithWrappers(evaluated, wrappers);
|
|
322
|
+
i = close + 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
appendCharWithWrappers(char, getWrappersAt(i));
|
|
326
|
+
i += 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
flushSegment();
|
|
330
|
+
const interpolatedText = resultChars.join('');
|
|
331
|
+
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
|
|
332
|
+
return { text: interpolatedText, markup: normalizedMarkup };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
|
|
336
|
+
if (!result) return undefined;
|
|
337
|
+
if (result.segments.length === 0) {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
const hasFormatting = result.segments.some(
|
|
341
|
+
(segment) => segment.wrappers.length > 0 || segment.selfClosing
|
|
342
|
+
);
|
|
343
|
+
if (!hasFormatting) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
text: result.text,
|
|
348
|
+
segments: result.segments.map((segment) => ({
|
|
349
|
+
start: segment.start,
|
|
350
|
+
end: segment.end,
|
|
351
|
+
wrappers: segment.wrappers.map((wrapper) => ({
|
|
352
|
+
name: wrapper.name,
|
|
353
|
+
type: wrapper.type,
|
|
354
|
+
properties: { ...wrapper.properties },
|
|
355
|
+
})),
|
|
356
|
+
selfClosing: segment.selfClosing,
|
|
357
|
+
})),
|
|
358
|
+
};
|
|
217
359
|
}
|
|
218
360
|
|
|
219
361
|
private resumeBlock(): boolean {
|
|
@@ -229,9 +371,11 @@ export class YarnRunner {
|
|
|
229
371
|
return true;
|
|
230
372
|
}
|
|
231
373
|
switch (ins.op) {
|
|
232
|
-
case "line":
|
|
233
|
-
|
|
374
|
+
case "line": {
|
|
375
|
+
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
|
376
|
+
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
|
|
234
377
|
return true;
|
|
378
|
+
}
|
|
235
379
|
case "command": {
|
|
236
380
|
try {
|
|
237
381
|
const parsed = parseCommand(ins.content);
|
|
@@ -244,7 +388,7 @@ export class YarnRunner {
|
|
|
244
388
|
return true;
|
|
245
389
|
}
|
|
246
390
|
case "options": {
|
|
247
|
-
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags })), isDialogueEnd: false });
|
|
391
|
+
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags, markup: o.markup })), isDialogueEnd: false });
|
|
248
392
|
return true;
|
|
249
393
|
}
|
|
250
394
|
case "if": {
|
|
@@ -294,9 +438,11 @@ export class YarnRunner {
|
|
|
294
438
|
}
|
|
295
439
|
this.ip++;
|
|
296
440
|
switch (ins.op) {
|
|
297
|
-
case "line":
|
|
298
|
-
|
|
441
|
+
case "line": {
|
|
442
|
+
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
|
443
|
+
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
|
299
444
|
return;
|
|
445
|
+
}
|
|
300
446
|
case "command": {
|
|
301
447
|
try {
|
|
302
448
|
const parsed = parseCommand(ins.content);
|
|
@@ -327,7 +473,7 @@ export class YarnRunner {
|
|
|
327
473
|
continue;
|
|
328
474
|
}
|
|
329
475
|
case "options": {
|
|
330
|
-
this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string }) => ({ text: o.text, tags: o.tags, css: o.css })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
|
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() });
|
|
331
477
|
return;
|
|
332
478
|
}
|
|
333
479
|
case "if": {
|
|
@@ -368,10 +514,12 @@ export class YarnRunner {
|
|
|
368
514
|
const ins = tempNode.instructions[idx++];
|
|
369
515
|
if (!ins) break;
|
|
370
516
|
switch (ins.op) {
|
|
371
|
-
case "line":
|
|
372
|
-
|
|
517
|
+
case "line": {
|
|
518
|
+
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
|
519
|
+
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, markup: interpolatedMarkup, isDialogueEnd: false });
|
|
373
520
|
restore();
|
|
374
521
|
return;
|
|
522
|
+
}
|
|
375
523
|
case "command":
|
|
376
524
|
try {
|
|
377
525
|
const parsed = parseCommand(ins.content);
|
|
@@ -384,7 +532,7 @@ export class YarnRunner {
|
|
|
384
532
|
restore();
|
|
385
533
|
return;
|
|
386
534
|
case "options":
|
|
387
|
-
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text })), isDialogueEnd: false });
|
|
535
|
+
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, markup: o.markup })), isDialogueEnd: false });
|
|
388
536
|
// Maintain context that options belong to main node at ip-1
|
|
389
537
|
restore();
|
|
390
538
|
return;
|
|
@@ -445,6 +593,14 @@ export class YarnRunner {
|
|
|
445
593
|
private emit(res: RuntimeResult) {
|
|
446
594
|
this.currentResult = res;
|
|
447
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
|
+
}
|
|
448
604
|
// If we ended a detour node, return to caller after emitting last result
|
|
449
605
|
// Position is restored here, but we wait for next advance() to continue
|
|
450
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
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { strictEqual, ok } from "node:assert";
|
|
3
|
+
import { parseMarkup } from "../markup/parser.js";
|
|
4
|
+
import type { MarkupParseResult } from "../markup/types.js";
|
|
5
|
+
|
|
6
|
+
test("parseMarkup handles default HTML tags", () => {
|
|
7
|
+
const result = parseMarkup("This is [b]bold[/b] and [em]emphasized[/em].");
|
|
8
|
+
|
|
9
|
+
strictEqual(result.text, "This is bold and emphasized.");
|
|
10
|
+
const bold = findSegment(result, "bold");
|
|
11
|
+
ok(bold, "Expected bold segment");
|
|
12
|
+
strictEqual(bold.wrappers.length, 1);
|
|
13
|
+
strictEqual(bold.wrappers[0].name, "b");
|
|
14
|
+
strictEqual(bold.wrappers[0].type, "default");
|
|
15
|
+
|
|
16
|
+
const em = findSegment(result, "emphasized");
|
|
17
|
+
ok(em, "Expected em segment");
|
|
18
|
+
strictEqual(em.wrappers[0].name, "em");
|
|
19
|
+
strictEqual(em.wrappers[0].type, "default");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parseMarkup exposes custom tags with properties", () => {
|
|
23
|
+
const result = parseMarkup("Say [wave speed=2 tone=\"high\"]hello[/wave]!");
|
|
24
|
+
strictEqual(result.text, "Say hello!");
|
|
25
|
+
|
|
26
|
+
const segment = findSegment(result, "hello");
|
|
27
|
+
ok(segment, "Expected wave segment");
|
|
28
|
+
const wrapper = segment.wrappers.find((w) => w.name === "wave");
|
|
29
|
+
ok(wrapper, "Expected wave wrapper");
|
|
30
|
+
strictEqual(wrapper.type, "custom");
|
|
31
|
+
strictEqual(wrapper.properties.speed, 2);
|
|
32
|
+
strictEqual(wrapper.properties.tone, "high");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("parseMarkup handles self-closing tags", () => {
|
|
36
|
+
const result = parseMarkup("[pause length=500/] Ready.");
|
|
37
|
+
strictEqual(result.text, " Ready.");
|
|
38
|
+
ok(
|
|
39
|
+
result.segments.some(
|
|
40
|
+
(seg) => seg.selfClosing && seg.wrappers.some((wrapper) => wrapper.name === "pause")
|
|
41
|
+
),
|
|
42
|
+
"Expected self closing pause marker"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parseMarkup respects nomarkup blocks and escaping", () => {
|
|
47
|
+
const result = parseMarkup(`[nomarkup][b] raw [/b][/nomarkup] and \\[escaped\\]`);
|
|
48
|
+
strictEqual(result.text, "[b] raw [/b] and [escaped]");
|
|
49
|
+
ok(
|
|
50
|
+
result.segments.every((segment) => segment.wrappers.length === 0),
|
|
51
|
+
"Expected no wrappers when using nomarkup and escaping"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function findSegment(result: MarkupParseResult, target: string) {
|
|
56
|
+
return result.segments.find((segment) => {
|
|
57
|
+
if (segment.selfClosing) return false;
|
|
58
|
+
const text = result.text.slice(segment.start, segment.end);
|
|
59
|
+
return text === target;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
-
import { strictEqual } from "node:assert";
|
|
2
|
+
import { strictEqual, ok } from "node:assert";
|
|
3
3
|
import { parseYarn, compile, YarnRunner } from "../index.js";
|
|
4
4
|
|
|
5
5
|
test("nodes and lines delivery", () => {
|
|
@@ -25,3 +25,37 @@ Narrator: Line two
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
test("markup parsing propagates to runtime", () => {
|
|
31
|
+
const script = `
|
|
32
|
+
title: Start
|
|
33
|
+
---
|
|
34
|
+
Narrator: Plain [b]bold[/b] [wave speed=2]custom[/wave]
|
|
35
|
+
===
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const doc = parseYarn(script);
|
|
39
|
+
const ir = compile(doc);
|
|
40
|
+
const runner = new YarnRunner(ir, { startAt: "Start" });
|
|
41
|
+
|
|
42
|
+
const result = runner.currentResult;
|
|
43
|
+
ok(result && result.type === "text", "Expected text result with markup");
|
|
44
|
+
ok(result?.markup, "Expected markup data to be present");
|
|
45
|
+
const markup = result!.markup!;
|
|
46
|
+
strictEqual(markup.text, "Plain bold custom");
|
|
47
|
+
|
|
48
|
+
const boldSegment = markup.segments.find((segment) =>
|
|
49
|
+
segment.wrappers.some((wrapper) => wrapper.name === "b" && wrapper.type === "default")
|
|
50
|
+
);
|
|
51
|
+
ok(boldSegment, "Expected bold segment");
|
|
52
|
+
strictEqual(markup.text.slice(boldSegment!.start, boldSegment!.end), "bold");
|
|
53
|
+
|
|
54
|
+
const customSegment = markup.segments.find((segment) =>
|
|
55
|
+
segment.wrappers.some((wrapper) => wrapper.name === "wave" && wrapper.type === "custom")
|
|
56
|
+
);
|
|
57
|
+
ok(customSegment, "Expected custom segment");
|
|
58
|
+
const waveWrapper = customSegment!.wrappers.find((wrapper) => wrapper.name === "wave");
|
|
59
|
+
ok(waveWrapper);
|
|
60
|
+
strictEqual(waveWrapper!.properties.speed, 2);
|
|
61
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
-
import { strictEqual } from "node:assert";
|
|
2
|
+
import { strictEqual, ok } from "node:assert";
|
|
3
3
|
import { parseYarn, compile, YarnRunner } from "../index.js";
|
|
4
4
|
|
|
5
5
|
test("options selection", () => {
|
|
@@ -32,3 +32,41 @@ Narrator: Choose one
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|