init: yarn-spinner

This commit is contained in:
hypercross 2026-04-14 14:57:03 +08:00
commit 9942bd9a7f
12 changed files with 2780 additions and 0 deletions

View File

@ -0,0 +1,136 @@
import type {YarnDocument, Statement, Line, Option, YarnNode} from "../model/ast";
import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir";
export interface CompileOptions {
generateOnceIds?: (ctx: { node: string; index: number }) => string;
}
export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram {
const program: IRProgram = { enums: {}, nodes: {} };
// Store enum definitions
for (const enumDef of doc.enums) {
program.enums[enumDef.name] = enumDef.cases;
}
const genOnce = opts.generateOnceIds ?? ((x) => `${x.node}#once#${x.index}`);
let globalLineCounter = 0;
function ensureLineId(tags?: string[]): string[] | undefined {
const t = tags ? [...tags] : [];
if (!t.some((x) => x.startsWith("line:"))) {
t.push(`line:${(globalLineCounter++).toString(16)}`);
}
return t;
}
// Group nodes by title to handle node groups
const nodesByTitle = new Map<string, typeof doc.nodes>();
for (const node of doc.nodes) {
if (!nodesByTitle.has(node.title)) {
nodesByTitle.set(node.title, []);
}
nodesByTitle.get(node.title)!.push(node);
}
let onceCounter = 0;
function emitBlock(stmts: Statement[], node: YarnNode): IRInstruction[] {
const block: IRInstruction[] = [];
for (const s of stmts) {
switch (s.type) {
case "Line":
{
const line = s as Line;
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
}
break;
case "Command":
block.push({ op: "command", content: s.content });
break;
case "Jump":
block.push({ op: "jump", target: s.target });
break;
case "Detour":
block.push({ op: "detour", target: s.target });
break;
case "Return":
block.push({ op: "return" });
break;
case "OptionGroup": {
// Add #lastline tag to the most recent line, if present
for (let i = block.length - 1; i >= 0; i--) {
const ins = block[i];
if (ins.op === "line") {
const tags = new Set(ins.tags ?? []);
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
tags.add("lastline");
}
ins.tags = Array.from(tags);
break;
}
if (ins.op !== "command") break; // stop if non-line non-command before options
}
block.push({
op: "options",
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body, node) })),
});
break;
}
case "If":
block.push({
op: "if",
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body, node) })),
});
break;
case "Once":
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body, node) });
break;
case "Enum":
// Enums are metadata, skip during compilation (already stored in program.enums)
break;
}
}
return block;
}
for (const [title, nodesWithSameTitle] of nodesByTitle) {
// If only one node with this title, treat as regular node
if (nodesWithSameTitle.length === 1) {
const node = nodesWithSameTitle[0];
const instructions: IRInstruction[] = [];
onceCounter = 0;
instructions.push(...emitBlock(node.body, node));
const irNode: IRNode = {
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
};
program.nodes[node.title] = irNode;
} else {
// Multiple nodes with same title - create node group
const groupNodes: IRNode[] = [];
for (const node of nodesWithSameTitle) {
const instructions: IRInstruction[] = [];
onceCounter = 0;
instructions.push(...emitBlock(node.body, node));
groupNodes.push({
title: node.title,
instructions,
when: node.when,
css: (node as any).css,
scene: node.headers.scene?.trim() || undefined
});
}
const group: IRNodeGroup = {
title,
nodes: groupNodes
};
program.nodes[title] = group;
}
}
return program;
}

View File

@ -0,0 +1,29 @@
import type { MarkupParseResult } from "../markup/types";
export type IRProgram = {
enums: Record<string, string[]>; // enum name -> cases
nodes: Record<string, IRNode | IRNodeGroup>; // can be single node or group
};
export type IRNode = {
title: string;
instructions: IRInstruction[];
when?: string[]; // Array of when conditions
css?: string;
scene?: string; // Scene name from node header
};
export type IRNodeGroup = {
title: string;
nodes: IRNode[]; // Multiple nodes with same title, different when conditions
};
export type IRInstruction =
| { op: "line"; speaker?: string; text: string; tags?: string[]; markup?: MarkupParseResult }
| { op: "command"; content: string }
| { op: "jump"; target: string }
| { op: "detour"; target: string }
| { op: "return"; }
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> }
| { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
| { op: "once"; id: string; block: IRInstruction[] };

View File

@ -0,0 +1,3 @@
export {parseYarn} from './parse/parser';
export {compile} from './compile/compiler';
export {YarnRunner} from "./runtime/runner";

View File

@ -0,0 +1,381 @@
import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types";
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
const SELF_CLOSING_TAGS = new Set(["br"]);
interface StackEntry {
name: string;
type: MarkupWrapper["type"];
properties: Record<string, MarkupValue>;
originalText: string;
}
interface ParsedTag {
kind: "open" | "close" | "self";
name: string;
properties: Record<string, MarkupValue>;
}
const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
const ATTRIBUTE_REGEX =
/^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
export function parseMarkup(input: string): MarkupParseResult {
const segments: MarkupSegment[] = [];
const stack: StackEntry[] = [];
const chars: string[] = [];
let currentSegment: MarkupSegment | null = null;
let nomarkupDepth = 0;
const pushSegment = (segment: MarkupSegment) => {
if (segment.selfClosing || segment.end > segment.start) {
segments.push(segment);
}
};
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const wa = a[i];
const wb = b[i];
if (wa.name !== wb.name || wa.type !== wb.type) return false;
const keysA = Object.keys(wa.properties);
const keysB = Object.keys(wb.properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (wa.properties[key] !== wb.properties[key]) return false;
}
}
return true;
};
const flushCurrentSegment = () => {
if (currentSegment) {
segments.push(currentSegment);
currentSegment = null;
}
};
const cloneWrappers = (): MarkupWrapper[] =>
stack.map((entry) => ({
name: entry.name,
type: entry.type,
properties: { ...entry.properties },
}));
const appendChar = (char: string) => {
const index = chars.length;
chars.push(char);
const wrappers = cloneWrappers();
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
currentSegment.end = index + 1;
} else {
flushCurrentSegment();
currentSegment = {
start: index,
end: index + 1,
wrappers,
};
}
};
const appendLiteral = (literal: string) => {
for (const ch of literal) {
appendChar(ch);
}
};
const parseTag = (contentRaw: string): ParsedTag | null => {
let content = contentRaw.trim();
if (!content) return null;
if (content.startsWith("/")) {
const name = content.slice(1).trim().toLowerCase();
if (!name) return null;
return { kind: "close", name, properties: {} };
}
let kind: ParsedTag["kind"] = "open";
if (content.endsWith("/")) {
content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
if (content.endsWith("/")) {
content = content.slice(0, -1).trim();
}
kind = "self";
}
const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
if (!nameMatch) return null;
const name = nameMatch[1].toLowerCase();
let rest = content.slice(nameMatch[0].length).trim();
const properties: Record<string, MarkupValue> = {};
while (rest.length > 0) {
const attrMatch = rest.match(ATTRIBUTE_REGEX);
if (!attrMatch) {
break;
}
const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
const key = keyRaw.toLowerCase();
let value: MarkupValue = true;
const rawValue = doubleQuoted ?? singleQuoted ?? bare;
if (rawValue !== undefined) {
value = parseAttributeValue(rawValue);
}
properties[key] = value;
rest = rest.slice(attrMatch[0].length).trim();
}
const finalKind: ParsedTag["kind"] = kind === "self" || SELF_CLOSING_TAGS.has(name) ? "self" : kind;
return { kind: finalKind, name, properties };
};
const parseAttributeValue = (raw: string): MarkupValue => {
const trimmed = raw.trim();
if (/^(true|false)$/i.test(trimmed)) {
return /^true$/i.test(trimmed);
}
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num;
}
}
return trimmed;
};
const handleSelfClosing = (tag: ParsedTag) => {
const wrapper: MarkupWrapper = {
name: tag.name,
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
properties: tag.properties,
};
const position = chars.length;
pushSegment({
start: position,
end: position,
wrappers: [wrapper],
selfClosing: true,
});
};
let i = 0;
while (i < input.length) {
const char = input[i];
if (char === "\\" && i + 1 < input.length) {
const next = input[i + 1];
if (next === "[" || next === "]" || next === "\\") {
appendChar(next);
i += 2;
continue;
}
}
if (char === "[") {
const closeIndex = findClosingBracket(input, i + 1);
if (closeIndex === -1) {
appendChar(char);
i += 1;
continue;
}
const content = input.slice(i + 1, closeIndex);
const originalText = input.slice(i, closeIndex + 1);
const parsed = parseTag(content);
if (!parsed) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.name === "nomarkup") {
if (parsed.kind === "open") {
nomarkupDepth += 1;
} else if (parsed.kind === "close" && nomarkupDepth > 0) {
nomarkupDepth -= 1;
}
i = closeIndex + 1;
continue;
}
if (nomarkupDepth > 0) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.kind === "open") {
const entry: StackEntry = {
name: parsed.name,
type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
properties: parsed.properties,
originalText,
};
stack.push(entry);
flushCurrentSegment();
i = closeIndex + 1;
continue;
}
if (parsed.kind === "self") {
handleSelfClosing(parsed);
i = closeIndex + 1;
continue;
}
// closing tag
if (stack.length === 0) {
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
const top = stack[stack.length - 1];
if (top.name === parsed.name) {
flushCurrentSegment();
stack.pop();
i = closeIndex + 1;
continue;
}
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
// mismatched closing; treat as literal
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
appendChar(char);
i += 1;
}
flushCurrentSegment();
// If any tags remain open, treat them as literal text appended at end
while (stack.length > 0) {
const entry = stack.pop()!;
appendLiteral(entry.originalText);
}
flushCurrentSegment();
const text = chars.join("");
return {
text,
segments: mergeSegments(segments, text.length),
};
}
function mergeSegments(segments: MarkupSegment[], textLength: number): MarkupSegment[] {
const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
const merged: MarkupSegment[] = [];
let last: MarkupSegment | null = null;
for (const seg of sorted) {
if (seg.start === seg.end && !seg.selfClosing) {
continue;
}
if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
last.end = seg.end;
} else {
last = {
start: seg.start,
end: seg.end,
wrappers: seg.wrappers,
selfClosing: seg.selfClosing,
};
merged.push(last);
}
}
if (merged.length === 0 && textLength > 0) {
merged.push({
start: 0,
end: textLength,
wrappers: [],
});
}
return merged;
}
function wrappersMatch(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].name !== b[i].name || a[i].type !== b[i].type) return false;
const keysA = Object.keys(a[i].properties);
const keysB = Object.keys(b[i].properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (a[i].properties[key] !== b[i].properties[key]) return false;
}
}
return true;
}
function findClosingBracket(text: string, start: number): number {
for (let i = start; i < text.length; i++) {
if (text[i] === "]") {
let backslashCount = 0;
let j = i - 1;
while (j >= 0 && text[j] === "\\") {
backslashCount++;
j--;
}
if (backslashCount % 2 === 0) {
return i;
}
}
}
return -1;
}
export function sliceMarkup(result: MarkupParseResult, start: number, end?: number): MarkupParseResult {
const textLength = result.text.length;
const sliceStart = Math.max(0, Math.min(start, textLength));
const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
const slicedSegments: MarkupSegment[] = [];
for (const seg of result.segments) {
const segStart = Math.max(seg.start, sliceStart);
const segEnd = Math.min(seg.end, sliceEnd);
if (seg.selfClosing) {
if (segStart >= sliceStart && segStart <= sliceEnd) {
slicedSegments.push({
start: segStart - sliceStart,
end: segStart - sliceStart,
wrappers: seg.wrappers,
selfClosing: true,
});
}
continue;
}
if (segEnd <= segStart) continue;
slicedSegments.push({
start: segStart - sliceStart,
end: segEnd - sliceStart,
wrappers: seg.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
});
}
if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
slicedSegments.push({
start: 0,
end: sliceEnd - sliceStart,
wrappers: [],
});
}
return {
text: result.text.slice(sliceStart, sliceEnd),
segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
};
}

View File

@ -0,0 +1,21 @@
export type MarkupValue = string | number | boolean;
export type MarkupWrapperType = "default" | "custom";
export interface MarkupWrapper {
name: string;
type: MarkupWrapperType;
properties: Record<string, MarkupValue>;
}
export interface MarkupSegment {
start: number;
end: number;
wrappers: MarkupWrapper[];
selfClosing?: boolean;
}
export interface MarkupParseResult {
text: string;
segments: MarkupSegment[];
}

View File

@ -0,0 +1,103 @@
export type Position = { line: number; column: number };
export interface NodeHeaderMap {
[key: string]: string;
}
export interface YarnDocument {
type: "Document";
enums: EnumDefinition[];
nodes: YarnNode[];
}
export interface EnumDefinition {
type: "Enum";
name: string;
cases: string[];
}
export interface YarnNode {
type: "Node";
title: string;
headers: NodeHeaderMap;
nodeTags?: string[];
when?: string[]; // Array of when conditions (can be "once", "always", or expression like "$has_sword")
css?: string; // Custom CSS style for node
body: Statement[];
}
export type Statement =
| Line
| Command
| OptionGroup
| IfBlock
| OnceBlock
| Jump
| Detour
| Return
| EnumBlock;
import type { MarkupParseResult } from "../markup/types.js";
export interface Line {
type: "Line";
speaker?: string;
text: string;
tags?: string[];
markup?: MarkupParseResult;
}
export interface Command {
type: "Command";
content: string; // inside << >>
}
export interface Jump {
type: "Jump";
target: string;
}
export interface Detour {
type: "Detour";
target: string;
}
export interface Return {
type: "Return";
target: string;
}
export interface OptionGroup {
type: "OptionGroup";
options: Option[];
}
export interface Option {
type: "Option";
text: string;
body: Statement[]; // executed if chosen
tags?: string[];
css?: string; // Custom CSS style for option
markup?: MarkupParseResult;
condition?: string;
}
export interface IfBlock {
type: "If";
branches: Array<{
condition: string | null; // null for else
body: Statement[];
}>;
}
export interface OnceBlock {
type: "Once";
body: Statement[];
}
export interface EnumBlock {
type: "Enum";
name: string;
cases: string[];
}

View File

@ -0,0 +1,107 @@
export interface Token {
type:
| "HEADER_KEY"
| "HEADER_VALUE"
| "NODE_START" // ---
| "NODE_END" // ===
| "OPTION" // ->
| "COMMAND" // <<...>> (single-line)
| "TEXT" // any non-empty content line
| "EMPTY"
| "INDENT"
| "DEDENT"
| "EOF";
text: string;
line: number;
column: number;
}
// Minimal indentation-sensitive lexer to support options and their bodies.
export function lex(input: string): Token[] {
const lines = input.replace(/\r\n?/g, "\n").split("\n");
const tokens: Token[] = [];
const indentStack: number[] = [0];
let inHeaders = true;
function push(type: Token["type"], text: string, line: number, column: number) {
tokens.push({ type, text, line, column });
}
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lineNum = i + 1;
const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
const content = raw.slice(indent.length);
if (content.trim() === "") {
push("EMPTY", "", lineNum, 1);
continue;
}
// Manage indentation tokens only within node bodies and on non-empty lines
if (!inHeaders) {
const prev = indentStack[indentStack.length - 1];
if (indent.length > prev) {
indentStack.push(indent.length);
push("INDENT", "", lineNum, 1);
} else if (indent.length < prev) {
while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
indentStack.pop();
push("DEDENT", "", lineNum, 1);
}
}
}
if (content === "---") {
inHeaders = false;
push("NODE_START", content, lineNum, indent.length + 1);
continue;
}
if (content === "===") {
inHeaders = true;
// flush indentation to root
while (indentStack.length > 1) {
indentStack.pop();
push("DEDENT", "", lineNum, 1);
}
push("NODE_END", content, lineNum, indent.length + 1);
continue;
}
// Header: key: value (only valid while inHeaders)
if (inHeaders) {
const m = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
if (m) {
push("HEADER_KEY", m[1], lineNum, indent.length + 1);
push("HEADER_VALUE", m[2], lineNum, indent.length + 1 + m[0].indexOf(m[2]));
continue;
}
}
if (content.startsWith("->")) {
push("OPTION", content.slice(2).trim(), lineNum, indent.length + 1);
continue;
}
// Commands like <<...>> (single line)
const cmd = content.match(/^<<(.+?)>>\s*$/);
if (cmd) {
push("COMMAND", cmd[1].trim(), lineNum, indent.length + 1);
continue;
}
// Plain text line
push("TEXT", content, lineNum, indent.length + 1);
}
// close remaining indentation at EOF
while (indentStack.length > 1) {
indentStack.pop();
tokens.push({ type: "DEDENT", text: "", line: lines.length, column: 1 });
}
tokens.push({ type: "EOF", text: "", line: lines.length + 1, column: 1 });
return tokens;
}

View File

@ -0,0 +1,540 @@
import { lex, Token } from "./lexer";
import { parseMarkup, sliceMarkup } from "../markup/parser";
import type { MarkupParseResult } from "../markup/types";
import type {
YarnDocument,
YarnNode,
Statement,
Line,
Command,
OptionGroup,
Option,
IfBlock,
OnceBlock,
Jump,
Detour,
Return,
EnumBlock,
} from "../model/ast";
export class ParseError extends Error {}
export function parseYarn(text: string): YarnDocument {
const tokens = lex(text);
const p = new Parser(tokens);
try{
return p.parseDocument();
}catch(e){
console.log(`parser status: `, p.status());
throw e;
}
}
class Parser {
private i = 0;
constructor(private readonly tokens: Token[]) {}
private peek(offset = 0) {
return this.tokens[this.i + offset];
}
private at(type: Token["type"]) {
return this.peek()?.type === type;
}
private take(type: Token["type"], err?: string): Token {
const t = this.peek();
if (!t || t.type !== type) throw new ParseError(err ?? `Expected ${type}, got ${t?.type}`);
this.i++;
return t;
}
private takeIf(type: Token["type"]) {
if (this.at(type)) return this.take(type);
return null;
}
parseDocument(): YarnDocument {
const enums: EnumBlock[] = [];
const nodes: YarnNode[] = [];
while (!this.at("EOF")) {
// Skip empties
while (this.at("EMPTY")) this.i++;
if (this.at("EOF")) break;
// Check if this is an enum definition (top-level)
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd.startsWith("enum ")) {
const enumCmd = this.take("COMMAND").text; // consume the enum command
const enumName = enumCmd.slice(5).trim();
const enumDef = this.parseEnumBlock(enumName);
enums.push(enumDef);
continue;
}
}
nodes.push(this.parseNode());
}
return { type: "Document", enums, nodes };
}
private parseNode(): YarnNode {
const headers: Record<string, string> = {};
let title: string | null = null;
let nodeTags: string[] | undefined;
let whenConditions: string[] = [];
let nodeCss: string | undefined;
// headers
while (!this.at("NODE_START")) {
const keyTok = this.take("HEADER_KEY", "Expected node header before '---'");
const valTok = this.take("HEADER_VALUE", "Expected header value");
// Capture &css{ ... } styles in any header value
const rawVal = valTok.text.trim();
let finalVal = valTok.text;
if (rawVal.startsWith("&css{")) {
// Collect until closing '}' possibly spanning multiple lines before '---'
let cssContent = rawVal.replace(/^&css\{/, "");
let closed = cssContent.includes("}");
if (closed) {
cssContent = cssContent.split("}")[0];
finalVal = rawVal.replace(/^&css\{[^}]*\}/, "").trim();
} else {
// Consume subsequent TEXT or HEADER_VALUE tokens until we find a '}'
while (!this.at("NODE_START") && !this.at("EOF")) {
const next = this.peek();
if (next.type === "TEXT" || next.type === "HEADER_VALUE") {
const t = this.take(next.type).text;
if (t.includes("}")) {
cssContent += (cssContent ? "\n" : "") + t.split("}")[0];
closed = true;
finalVal = t.split("}").slice(1).join("}").trim();
break;
} else {
cssContent += (cssContent ? "\n" : "") + t;
}
} else if (next.type === "EMPTY") {
this.i++;
} else {
break;
}
}
}
nodeCss = (cssContent || "").trim();
}
if (keyTok.text === "title") title = finalVal.trim();
if (keyTok.text === "tags") {
const raw = finalVal.trim();
nodeTags = raw.split(/\s+/).filter(Boolean);
}
if (keyTok.text === "when") {
// Each when: header adds one condition (can have multiple when: headers)
const raw = finalVal.trim();
whenConditions.push(raw);
}
headers[keyTok.text] = finalVal;
// allow empty lines
while (this.at("EMPTY")) this.i++;
}
if (!title) throw new ParseError("Every node must have a title header");
this.take("NODE_START");
// allow optional empties after ---
while (this.at("EMPTY")) this.i++;
const body: Statement[] = this.parseStatementsUntil("NODE_END");
this.take("NODE_END", "Expected node end '==='");
return {
type: "Node",
title,
headers,
nodeTags,
when: whenConditions.length > 0 ? whenConditions : undefined,
css: nodeCss,
body
};
}
private parseStatementsUntil(endType: Token["type"]): Statement[] {
const out: Statement[] = [];
while (!this.at(endType) && !this.at("EOF")) {
// skip extra empties
while (this.at("EMPTY")) this.i++;
if (this.at(endType) || this.at("EOF")) break;
// Handle plain indentation seamlessly within blocks
if (this.at("INDENT")) {
this.take("INDENT");
while (!this.at("DEDENT") && !this.at(endType) && !this.at("EOF")) {
while (this.at("EMPTY")) this.i++;
if (this.at("DEDENT") || this.at(endType) || this.at("EOF")) break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
if (this.at("DEDENT")) {
this.take("DEDENT");
while (this.at("EMPTY")) this.i++;
}
continue;
}
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
const stmt = this.parseStatement();
out.push(stmt);
}
return out;
}
private parseStatement(): Statement {
const t = this.peek();
if (!t) throw new ParseError("Unexpected EOF");
if (t.type === "COMMAND") {
const cmd = this.take("COMMAND").text;
if (cmd.startsWith("jump ")) return { type: "Jump", target: cmd.slice(5).trim() } as Jump;
if (cmd.startsWith("detour ")) return { type: "Detour", target: cmd.slice(7).trim() } as Detour;
if (cmd.startsWith("return")) return { type: "Return" } as Return;
if (cmd.startsWith("if ")) return this.parseIfCommandBlock(cmd);
if (cmd === "once") return this.parseOnceBlock();
if (cmd.startsWith("enum ")) {
const enumName = cmd.slice(5).trim();
return this.parseEnumBlock(enumName);
}
return { type: "Command", content: cmd } as Command;
}
if (t.type === "TEXT") {
const raw = this.take("TEXT").text;
const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
const markup = parseMarkup(textWithoutTags);
const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
if (speakerMatch) {
const messageText = speakerMatch[2];
const messageOffset = markup.text.length - messageText.length;
const slicedMarkup = sliceMarkup(markup, messageOffset);
const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
return {
type: "Line",
speaker: speakerMatch[1].trim(),
text: messageText,
tags,
markup: normalizedMarkup,
} as Line;
}
// If/Else blocks use inline markup {if ...}
const trimmed = markup.text.trim();
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
return this.parseIfFromText(markup.text);
}
return {
type: "Line",
text: markup.text,
tags,
markup: this.normalizeMarkup(markup),
} as Line;
}
throw new ParseError(`Unexpected token ${t.type}`);
}
private parseOptionGroup(): OptionGroup {
const options: Option[] = [];
// One or more OPTION lines, with bodies under INDENT
while (this.at("OPTION")) {
const raw = this.take("OPTION").text;
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
const markup = parseMarkup(optionText);
let body: Statement[] = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
while (this.at("EMPTY")) this.i++;
}
options.push({
type: "Option",
text: markup.text,
body,
tags,
css,
markup: this.normalizeMarkup(markup),
condition,
});
// Consecutive options belong to the same group; break on non-OPTION
while (this.at("EMPTY")) this.i++;
}
return { type: "OptionGroup", options };
}
private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
if (!result) return undefined;
if (result.segments.length === 0) {
return undefined;
}
const hasFormatting = result.segments.some(
(segment) => segment.wrappers.length > 0 || segment.selfClosing
);
if (!hasFormatting) {
return undefined;
}
return {
text: result.text,
segments: result.segments.map((segment) => ({
start: segment.start,
end: segment.end,
wrappers: segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
selfClosing: segment.selfClosing,
})),
};
}
private extractTags(input: string): { cleanText: string; tags?: string[] } {
const tags: string[] = [];
// Match tags that are space-separated and not part of hex colors or CSS
// Tags are like "#tag" preceded by whitespace and not followed by hex digits
const re = /\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
let text = input;
let m: RegExpExecArray | null;
while ((m = re.exec(input))) {
tags.push(m[1]);
}
if (tags.length > 0) {
// Only remove tags that match the pattern (not hex colors in CSS)
text = input.replace(/\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, "").trimEnd();
return { cleanText: text, tags };
}
return { cleanText: input };
}
private extractCss(input: string): { text: string; css?: string } {
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
if (cssMatch) {
const css = cssMatch[1].trim();
const text = input.replace(cssMatch[0], "").trimEnd();
return { text, css };
}
return { text: input };
}
private extractOptionCondition(input: string): { text: string; condition?: string } {
const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
if (match) {
const text = input.slice(0, match.index).trimEnd();
return { text, condition: match[1].trim() };
}
return { text: input };
}
private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
const out: Statement[] = [];
while (!this.at("EOF")) {
// Check stop condition at root level only
if (shouldStop()) break;
while (this.at("EMPTY")) this.i++;
if (this.at("EOF") || shouldStop()) break;
// Handle indentation - if we see INDENT, parse the indented block
if (this.at("INDENT")) {
this.take("INDENT");
// Parse statements at this indent level until DEDENT (don't check stop condition inside)
while (!this.at("DEDENT") && !this.at("EOF")) {
while (this.at("EMPTY")) this.i++;
if (this.at("DEDENT") || this.at("EOF")) break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
if (this.at("DEDENT")) {
this.take("DEDENT");
while (this.at("EMPTY")) this.i++;
}
continue;
}
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
return out;
}
private parseOnceBlock(): OnceBlock {
// Already consumed <<once>>; expect body under INDENT then <<endonce>> as COMMAND
let body: Statement[] = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
} else {
// Alternatively, body until explicit <<endonce>> command on single line
body = [];
}
// consume closing command if present on own line
if (this.at("COMMAND") && this.peek().text === "endonce") {
this.take("COMMAND");
}
return { type: "Once", body };
}
private parseIfFromText(firstLine: string): IfBlock {
const branches: IfBlock["branches"] = [];
// expecting state not required in current implementation
let cursor = firstLine.trim();
function parseCond(text: string) {
const mIf = text.match(/^\{if\s+(.+?)\}$/);
if (mIf) return mIf[1];
const mElIf = text.match(/^\{else\s+if\s+(.+?)\}$/);
if (mElIf) return mElIf[1];
return null;
}
while (true) {
const cond = parseCond(cursor);
if (cursor === "{else}") {
branches.push({ condition: null, body: this.parseIfBlockBody() });
// next must be {endif}
const endLine = this.take("TEXT", "Expected {endif}").text.trim();
if (endLine !== "{endif}") throw new ParseError("Expected {endif}");
break;
} else if (cond) {
branches.push({ condition: cond, body: this.parseIfBlockBody() });
// next control line
const next = this.take("TEXT", "Expected {else}, {else if}, or {endif}").text.trim();
if (next === "{endif}") break;
cursor = next;
continue;
} else if (cursor === "{endif}") {
break;
} else {
throw new ParseError("Invalid if/else control line");
}
}
return { type: "If", branches };
}
private parseEnumBlock(enumName: string): EnumBlock {
const cases: string[] = [];
// Parse cases until <<endenum>>
while (!this.at("EOF")) {
while (this.at("EMPTY")) this.i++;
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd === "endenum") {
this.take("COMMAND");
break;
}
if (cmd.startsWith("case ")) {
this.take("COMMAND");
const caseName = cmd.slice(5).trim();
cases.push(caseName);
} else {
// Unknown command, might be inside enum block - skip or break?
break;
}
} else {
// Skip non-command lines
if (this.at("TEXT")) this.take("TEXT");
}
}
return { type: "Enum", name: enumName, cases };
}
private parseIfCommandBlock(firstCmd: string): IfBlock {
const branches: IfBlock["branches"] = [];
const firstCond = firstCmd.slice(3).trim();
// Body until next elseif/else/endif command (check at root level, not inside indented blocks)
const firstBody = this.parseStatementsUntilStop(() => {
// Only stop at root level commands, not inside indented blocks
return this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text);
});
branches.push({ condition: firstCond, body: firstBody });
while (!this.at("EOF")) {
if (!this.at("COMMAND")) break;
const t = this.peek();
const txt = t.text.trim();
if (txt.startsWith("elseif ")) {
this.take("COMMAND");
const cond = txt.slice(7).trim();
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text));
branches.push({ condition: cond, body });
continue;
}
if (txt === "else") {
this.take("COMMAND");
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(endif$)/.test(this.peek().text));
branches.push({ condition: null, body });
// require endif after else body
if (this.at("COMMAND") && this.peek().text.trim() === "endif") {
this.take("COMMAND");
}
break;
}
if (txt === "endif") {
this.take("COMMAND");
break;
}
break;
}
return { type: "If", branches };
}
private parseIfBlockBody(): Statement[] {
// Body is indented lines until next control line or DEDENT boundary; to keep this simple
// we consume subsequent lines until encountering a control TEXT or EOF/OPTION/NODE_END.
const body: Statement[] = [];
while (!this.at("EOF") && !this.at("NODE_END")) {
// Stop when next TEXT is a control or when OPTION starts (new group)
if (this.at("TEXT")) {
const look = this.peek().text.trim();
if (look === "{else}" || look === "{endif}" || look.startsWith("{else if ") || look.startsWith("{if ")) break;
}
if (this.at("OPTION")) break;
// Support indented bodies inside if-branches
if (this.at("INDENT")) {
this.take("INDENT");
const nested = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
body.push(...nested);
// continue scanning after dedent
while (this.at("EMPTY")) this.i++;
continue;
}
if (this.at("EMPTY")) {
this.i++;
continue;
}
body.push(this.parseStatement());
}
return body;
}
public status(){
// find the first title before the current token
let closestNode = this.tokens.slice(0, this.i).reverse().findIndex(t => t.type === "HEADER_KEY" && t.text === "title");
return {
i: this.i,
tokens: this.tokens,
token: this.peek(),
closestNode: this.tokens[this.i - closestNode]
}
}
}

View File

@ -0,0 +1,199 @@
/**
* Command parser and handler utilities for Yarn Spinner commands.
* Commands like <<command_name arg1 arg2>> or <<command_name "arg with spaces">>
*/
import type { ExpressionEvaluator as Evaluator } from "./evaluator";
export interface ParsedCommand {
name: string;
args: string[];
raw: string;
}
/**
* Parse a command string like "command_name arg1 arg2" or "set variable value"
* Supports quoted strings with spaces, nested parentheses in expressions.
*/
export function parseCommand(content: string): ParsedCommand {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Empty command");
}
const parts: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
let parenDepth = 0;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
// Handle quote toggling (only when not inside parentheses)
if ((char === '"' || char === "'") && !inQuotes && parenDepth === 0) {
// Push accumulated non-quoted content as a part
if (current.trim()) {
parts.push(current.trim());
current = "";
}
inQuotes = true;
quoteChar = char;
continue;
}
if (char === quoteChar && inQuotes) {
// End of quoted string - preserve quotes in the output
parts.push(quoteChar + current + quoteChar);
quoteChar = "";
current = "";
inQuotes = false;
continue;
}
// Track parenthesis depth to avoid splitting inside expressions
if (char === "(" && !inQuotes) {
parenDepth++;
} else if (char === ")" && !inQuotes) {
parenDepth = Math.max(0, parenDepth - 1);
}
// Split on spaces only when not in quotes and not in parentheses
if (char === " " && !inQuotes && parenDepth === 0) {
if (current.trim()) {
parts.push(current.trim());
current = "";
}
continue;
}
current += char;
}
// Push any remaining content
if (current.trim()) {
parts.push(current.trim());
}
if (parts.length === 0) {
throw new Error("No command name found");
}
return {
name: parts[0],
args: parts.slice(1),
raw: content,
};
}
/**
* Built-in command handlers for common Yarn Spinner commands.
*/
export class CommandHandler {
private handlers = new Map<string, (args: string[], evaluator?: Evaluator) => void | Promise<void>>();
private variables: Record<string, unknown>;
constructor(variables: Record<string, unknown> = {}) {
this.variables = variables;
this.registerBuiltins();
}
/**
* Register a command handler.
*/
register(name: string, handler: (args: string[], evaluator?: Evaluator) => void | Promise<void>): void {
this.handlers.set(name.toLowerCase(), handler);
}
/**
* Execute a parsed command.
*/
async execute(parsed: ParsedCommand, evaluator?: Evaluator): Promise<void> {
const handler = this.handlers.get(parsed.name.toLowerCase());
if (handler) {
await handler(parsed.args, evaluator);
} else {
console.warn(`Unknown command: ${parsed.name}`);
}
}
private registerBuiltins(): void {
// <<set $var to expr>> or <<set $var = expr>> or <<set $var expr>>
this.register("set", (args, evaluator) => {
if (!evaluator) return;
if (args.length < 2) return;
const varNameRaw = args[0];
let exprParts = args.slice(1);
if (exprParts[0] === "to") exprParts = exprParts.slice(1);
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
const expr = exprParts.join(" ");
let value = evaluator.evaluateExpression(expr);
// If value is a string starting with ".", try to resolve as enum shorthand
if (typeof value === "string" && value.startsWith(".")) {
const enumType = evaluator.getEnumTypeForVariable(varNameRaw);
if (enumType) {
value = evaluator.resolveEnumValue(value, enumType);
}
}
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
// Setting a variable converts it from smart to regular
this.variables[key] = value;
evaluator.setVariable(key, value);
});
// <<declare $var = expr>>
this.register("declare", (args, evaluator) => {
if (!evaluator) return;
if (args.length < 3) return; // name, '=', expr
const varNameRaw = args[0];
let exprParts = args.slice(1);
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
const expr = exprParts.join(" ");
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
// Check if expression is "smart" (contains operators, comparisons, or variable references)
// Smart variables: expressions with operators, comparisons, logical ops, or function calls
const isSmart = /[+\-*/%<>=!&|]/.test(expr) ||
/\$\w+/.test(expr) || // references other variables
/[a-zA-Z_]\w*\s*\(/.test(expr); // function calls
if (isSmart) {
// Store as smart variable - will recalculate on each access
evaluator.setSmartVariable(key, expr);
// Also store initial value in variables for immediate use
const initialValue = evaluator.evaluateExpression(expr);
this.variables[key] = initialValue;
} else {
// Regular variable - evaluate once and store
let value = evaluator.evaluateExpression(expr);
// Check if expr is an enum value (EnumName.CaseName or .CaseName)
if (typeof value === "string") {
// Try to extract enum name from EnumName.CaseName
const enumMatch = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (enumMatch) {
const enumName = enumMatch[1];
value = evaluator.resolveEnumValue(expr, enumName);
} else if (value.startsWith(".")) {
// Shorthand - we can't infer enum type from declaration alone
// Store as-is, will be resolved on first use if variable has enum type
// Value is already set correctly above
}
}
this.variables[key] = value;
evaluator.setVariable(key, value);
}
});
// <<stop>> - no-op, just a marker
this.register("stop", () => {
// Dialogue stop marker
});
}
}

View File

@ -0,0 +1,558 @@
/**
* Safe expression evaluator for Yarn Spinner conditions.
* Supports variables, functions, comparisons, and logical operators.
*/
export class ExpressionEvaluator {
private smartVariables: Record<string, string> = {}; // variable name -> expression
constructor(
private variables: Record<string, unknown> = {},
private functions: Record<string, (...args: unknown[]) => unknown> = {},
private enums: Record<string, string[]> = {} // enum name -> cases
) {}
/**
* Evaluate a condition expression and return a boolean result.
* Supports: variables, literals (numbers, strings, booleans), comparisons, logical ops, function calls.
*/
evaluate(expr: string): boolean {
try {
const result = this.evaluateExpression(expr);
return !!result;
} catch {
return false;
}
}
/**
* Evaluate an expression that can return any value (not just boolean).
*/
evaluateExpression(expr: string): unknown {
const trimmed = this.preprocess(expr.trim());
if (!trimmed) return false;
// Handle function calls like `functionName(arg1, arg2)`
if (this.looksLikeFunctionCall(trimmed)) {
return this.evaluateFunctionCall(trimmed);
}
// Handle comparisons
if (this.containsComparison(trimmed)) {
return this.evaluateComparison(trimmed);
}
// Handle logical operators
if (trimmed.includes("&&") || trimmed.includes("||")) {
return this.evaluateLogical(trimmed);
}
// Handle negation
if (trimmed.startsWith("!")) {
return !this.evaluateExpression(trimmed.slice(1).trim());
}
// Handle arithmetic expressions (+, -, *, /, %)
if (this.containsArithmetic(trimmed)) {
return this.evaluateArithmetic(trimmed);
}
// Simple variable or literal
return this.resolveValue(trimmed);
}
private preprocess(expr: string): string {
// Normalize operator word aliases to JS-like symbols
// Whole word replacements only
return expr
.replace(/\bnot\b/gi, "!")
.replace(/\band\b/gi, "&&")
.replace(/\bor\b/gi, "||")
.replace(/\bxor\b/gi, "^")
.replace(/\beq\b|\bis\b/gi, "==")
.replace(/\bneq\b/gi, "!=")
.replace(/\bgte\b/gi, ">=")
.replace(/\blte\b/gi, "<=")
.replace(/\bgt\b/gi, ">")
.replace(/\blt\b/gi, "<");
}
private evaluateFunctionCall(expr: string): unknown {
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
if (!match) throw new Error(`Invalid function call: ${expr}`);
const [, name, argsStr] = match;
const func = this.functions[name];
if (!func) throw new Error(`Function not found: ${name}`);
const args = this.parseArguments(argsStr);
const evaluatedArgs = args.map((arg) => this.evaluateExpression(arg.trim()));
return func(...evaluatedArgs);
}
private parseArguments(argsStr: string): string[] {
if (!argsStr.trim()) return [];
const args: string[] = [];
let depth = 0;
let current = "";
for (const char of argsStr) {
if (char === "(") depth++;
else if (char === ")") depth--;
else if (char === "," && depth === 0) {
args.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) args.push(current.trim());
return args;
}
private containsComparison(expr: string): boolean {
return /[<>=!]/.test(expr);
}
private looksLikeFunctionCall(expr: string): boolean {
return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
}
private containsArithmetic(expr: string): boolean {
// Remove quoted strings to avoid false positives on "-" or "+" inside literals
const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
return /[+\-*/%]/.test(unquoted);
}
private evaluateArithmetic(expr: string): number {
const input = expr;
let index = 0;
const skipWhitespace = () => {
while (index < input.length && /\s/.test(input[index])) {
index++;
}
};
const toNumber = (value: unknown): number => {
if (typeof value === "number") return value;
if (typeof value === "boolean") return value ? 1 : 0;
if (value == null || value === "") return 0;
const num = Number(value);
if (Number.isNaN(num)) {
throw new Error(`Cannot convert ${String(value)} to number`);
}
return num;
};
const readToken = (): string => {
skipWhitespace();
const start = index;
let depth = 0;
let inQuotes = false;
let quoteChar = "";
while (index < input.length) {
const char = input[index];
if (inQuotes) {
if (char === quoteChar) {
inQuotes = false;
quoteChar = "";
}
index++;
continue;
}
if (char === '"' || char === "'") {
inQuotes = true;
quoteChar = char;
index++;
continue;
}
if (char === "(") {
depth++;
index++;
continue;
}
if (char === ")") {
if (depth === 0) break;
depth--;
index++;
continue;
}
if (depth === 0 && "+-*/%".includes(char)) {
break;
}
if (depth === 0 && /\s/.test(char)) {
break;
}
index++;
}
return input.slice(start, index).trim();
};
const parsePrimary = (): unknown => {
skipWhitespace();
if (index >= input.length) {
throw new Error("Unexpected end of expression");
}
const char = input[index];
if (char === "(") {
index++;
const value = parseAddSub();
skipWhitespace();
if (input[index] !== ")") {
throw new Error("Unmatched parenthesis in expression");
}
index++;
return value;
}
const token = readToken();
if (!token) {
throw new Error("Invalid expression token");
}
return this.evaluateExpression(token);
};
const parseUnary = (): number => {
skipWhitespace();
if (input[index] === "+") {
index++;
return parseUnary();
}
if (input[index] === "-") {
index++;
return -parseUnary();
}
return toNumber(parsePrimary());
};
const parseMulDiv = (): number => {
let value = parseUnary();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "*" || char === "/" || char === "%") {
index++;
const right = parseUnary();
if (char === "*") {
value = value * right;
} else if (char === "/") {
value = value / right;
} else {
value = value % right;
}
continue;
}
break;
}
return value;
};
const parseAddSub = (): number => {
let value = parseMulDiv();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "+" || char === "-") {
index++;
const right = parseMulDiv();
if (char === "+") {
value = value + right;
} else {
value = value - right;
}
continue;
}
break;
}
return value;
};
const result = parseAddSub();
skipWhitespace();
if (index < input.length) {
throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
}
return result;
}
private evaluateComparison(expr: string): boolean {
// Match comparison operators (avoid matching !=, <=, >=)
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
if (!match) throw new Error(`Invalid comparison: ${expr}`);
const [, left, rawOp, right] = match;
const op = rawOp === "=" ? "==" : rawOp;
const leftVal = this.evaluateExpression(left.trim());
const rightVal = this.evaluateExpression(right.trim());
switch (op) {
case "===":
case "==":
return this.deepEquals(leftVal, rightVal);
case "!==":
case "!=":
return !this.deepEquals(leftVal, rightVal);
case "<":
return Number(leftVal) < Number(rightVal);
case ">":
return Number(leftVal) > Number(rightVal);
case "<=":
return Number(leftVal) <= Number(rightVal);
case ">=":
return Number(leftVal) >= Number(rightVal);
default:
throw new Error(`Unknown operator: ${op}`);
}
}
private evaluateLogical(expr: string): boolean {
// Split by && or ||, respecting parentheses
const parts: Array<{ expr: string; op: "&&" | "||" | null }> = [];
let depth = 0;
let current = "";
let lastOp: "&&" | "||" | null = null;
let i = 0;
while (i < expr.length) {
const char = expr[i];
if (char === "(") {
depth++;
current += char;
i++;
continue;
}
if (char === ")") {
depth--;
current += char;
i++;
continue;
}
// Check for && or || at current position (only at depth 0)
if (depth === 0) {
if (expr.slice(i, i + 2) === "&&") {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "&&";
i += 2;
continue;
}
if (expr.slice(i, i + 2) === "||") {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "||";
i += 2;
continue;
}
}
current += char;
i++;
}
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
}
// Simple case: single expression
if (parts.length <= 1) {
return !!this.evaluateExpression(expr);
}
// Evaluate parts with short-circuit logic
let result = this.evaluateExpression(parts[0].expr);
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (part.op === "&&") {
// Short-circuit: if result is false, no need to evaluate further
if (!result) return false;
result = result && this.evaluateExpression(part.expr);
} else if (part.op === "||") {
// Short-circuit: if result is true, no need to evaluate further
if (result) return true;
result = result || this.evaluateExpression(part.expr);
}
}
return !!result;
}
private resolveValue(expr: string): unknown {
// Try enum syntax: EnumName.CaseName or .CaseName
const enumMatch = expr.match(/^\.?([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (enumMatch) {
const [, enumName, caseName] = enumMatch;
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`; // Store as "EnumName.CaseName" string
}
}
// Try shorthand enum: .CaseName (requires context from variables)
if (expr.startsWith(".") && expr.length > 1) {
// Try to infer enum from variable types - for now, return as-is and let validation handle it
return expr;
}
// Try as variable first
const key = expr.startsWith("$") ? expr.slice(1) : expr;
// Check if this is a smart variable (has stored expression)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, key)) {
// Re-evaluate the expression each time it's accessed
return this.evaluateExpression(this.smartVariables[key]);
}
if (Object.prototype.hasOwnProperty.call(this.variables, key)) {
return this.variables[key];
}
// Try as number
const num = Number(expr);
if (!isNaN(num) && expr.trim() === String(num)) {
return num;
}
// Try as boolean
if (expr === "true") return true;
if (expr === "false") return false;
// Try as string (quoted)
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1);
}
// Default: treat as variable (may be undefined)
return this.variables[key];
}
/**
* Resolve shorthand enum (.CaseName) when setting a variable with known enum type
*/
resolveEnumValue(expr: string, enumName?: string): string {
if (expr.startsWith(".") && enumName) {
const caseName = expr.slice(1);
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`;
}
throw new Error(`Invalid enum case ${caseName} for enum ${enumName}`);
}
// Check if it's already EnumName.CaseName format
const match = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (match) {
const [, name, caseName] = match;
if (this.enums[name] && this.enums[name].includes(caseName)) {
return expr;
}
throw new Error(`Invalid enum case ${caseName} for enum ${name}`);
}
return expr;
}
/**
* Get enum type for a variable (if it was declared with enum type)
*/
getEnumTypeForVariable(varName: string): string | undefined {
// Check if variable value matches EnumName.CaseName pattern
const key = varName.startsWith("$") ? varName.slice(1) : varName;
const value = this.variables[key];
if (typeof value === "string") {
const match = value.match(/^([A-Za-z_][A-Za-z0-9_]*)\./);
if (match) {
return match[1];
}
}
return undefined;
}
private deepEquals(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === "object") {
// Handle arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!this.deepEquals(a[i], b[i])) return false;
}
return true;
}
// Handle plain objects
if (!Array.isArray(a) && !Array.isArray(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!this.deepEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false;
}
}
return true;
}
// Mixed types (array vs object)
return false;
}
return false;
}
/**
* Update variables. Can be used to mutate state during dialogue.
*/
setVariable(name: string, value: unknown): void {
// If setting a smart variable, remove it (converting to regular variable)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, name)) {
delete this.smartVariables[name];
}
this.variables[name] = value;
}
/**
* Register a smart variable (variable with expression that recalculates on access).
*/
setSmartVariable(name: string, expression: string): void {
// Remove from regular variables if it exists
if (Object.prototype.hasOwnProperty.call(this.variables, name)) {
delete this.variables[name];
}
this.smartVariables[name] = expression;
}
/**
* Check if a variable is a smart variable.
*/
isSmartVariable(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this.smartVariables, name);
}
/**
* Get variable value.
*/
getVariable(name: string): unknown {
return this.variables[name];
}
registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
this.functions = {
...this.functions,
...functions
}
}
}

View File

@ -0,0 +1,41 @@
import type { MarkupParseResult } from "../markup/types";
/**
* Result emitted when the dialogue produces text/dialogue.
*/
export type TextResult = {
type: "text";
text: string;
speaker?: string;
tags?: string[];
markup?: MarkupParseResult;
nodeCss?: string; // Node-level CSS from &css{} header
scene?: string; // Scene name from node header
isDialogueEnd: boolean;
};
/**
* Result emitted when the dialogue presents options to the user.
*/
export type OptionsResult = {
type: "options";
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
nodeCss?: string; // Node-level CSS from &css{} header
scene?: string; // Scene name from node header
isDialogueEnd: boolean;
};
/**
* Result emitted when the dialogue executes a command.
*/
export type CommandResult = {
type: "command";
command: string;
isDialogueEnd: boolean;
};
/**
* Union type of all possible runtime results emitted by the YarnRunner.
*/
export type RuntimeResult = TextResult | OptionsResult | CommandResult;

View File

@ -0,0 +1,662 @@
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types";
import type { RuntimeResult } from "./results";
import { ExpressionEvaluator } from "./evaluator";
import { CommandHandler, parseCommand } from "./commands";
export interface RunnerOptions {
startAt: string;
variables?: Record<string, unknown>;
functions?: Record<string, (...args: unknown[]) => unknown>;
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
commandHandler?: CommandHandler;
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
/**
* If true, each runner instance maintains its own once-seen state.
* If false (default), all runners share global once-seen state.
*/
isolated?: boolean;
}
// Global shared state for once-seen tracking (default behavior)
const globalOnceSeen = new Set<string>();
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
type CompiledOption = {
text: string;
tags?: string[];
css?: string;
markup?: MarkupParseResult;
condition?: string;
block: IRInstruction[];
};
type CallStackFrame =
| { kind: "detour"; title: string; ip: number }
| { kind: "block"; title: string; ip: number; block: IRInstruction[]; idx: number };
export class YarnRunner {
private readonly program: IRProgram;
private readonly variables: Record<string, unknown>;
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
private readonly commandHandler: CommandHandler;
private readonly evaluator: ExpressionEvaluator;
private readonly onceSeen: Set<string>;
private readonly nodeGroupOnceSeen: Set<string>;
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
private storyEnded = false;
private readonly visitCounts: Record<string, number> = {};
private pendingOptions: CompiledOption[] | null = null;
private nodeTitle: string;
private ip = 0; // instruction pointer within node
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
private callStack: CallStackFrame[] = [];
currentResult: RuntimeResult | null = null;
history: RuntimeResult[] = [];
constructor(program: IRProgram, opts: RunnerOptions) {
this.program = program;
this.variables = {};
if (opts.variables) {
for (const [key, value] of Object.entries(opts.variables)) {
const normalizedKey = key.startsWith("$") ? key.slice(1) : key;
this.variables[normalizedKey] = value;
}
}
// Use isolated state if requested, otherwise share global state
if (opts.isolated) {
this.onceSeen = new Set<string>();
this.nodeGroupOnceSeen = new Set<string>();
} else {
this.onceSeen = globalOnceSeen;
this.nodeGroupOnceSeen = globalNodeGroupOnceSeen;
}
let functions = {
// Default conversion helpers
string: (v: unknown) => String(v ?? ""),
number: (v: unknown) => Number(v),
bool: (v: unknown) => Boolean(v),
visited: (nodeName: unknown) => {
const name = String(nodeName ?? "");
return (this.visitCounts[name] ?? 0) > 0;
},
visited_count: (nodeName: unknown) => {
const name = String(nodeName ?? "");
return this.visitCounts[name] ?? 0;
},
format_invariant: (n: unknown) => {
const num = Number(n);
if (!isFinite(num)) return "0";
return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
},
random: () => Math.random(),
random_range: (a: unknown, b: unknown) => {
const x = Number(a), y = Number(b);
const min = Math.min(x, y);
const max = Math.max(x, y);
return min + Math.random() * (max - min);
},
dice: (sides: unknown) => {
const s = Math.max(1, Math.floor(Number(sides)) || 1);
return Math.floor(Math.random() * s) + 1;
},
min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
round: (n: unknown) => Math.round(Number(n)),
round_places: (n: unknown, places: unknown) => {
const p = Math.max(0, Math.floor(Number(places)) || 0);
const factor = Math.pow(10, p);
return Math.round(Number(n) * factor) / factor;
},
floor: (n: unknown) => Math.floor(Number(n)),
ceil: (n: unknown) => Math.ceil(Number(n)),
inc: (n: unknown) => {
const v = Number(n);
return Number.isInteger(v) ? v + 1 : Math.ceil(v);
},
dec: (n: unknown) => {
const v = Number(n);
return Number.isInteger(v) ? v - 1 : Math.floor(v);
},
decimal: (n: unknown) => {
const v = Number(n);
return Math.abs(v - Math.trunc(v));
},
int: (n: unknown) => Math.trunc(Number(n)),
...(opts.functions ?? {}),
} as Record<string, (...args: unknown[]) => unknown>;
this.handleCommand = opts.handleCommand;
this.onStoryEnd = opts.onStoryEnd;
this.evaluator = new ExpressionEvaluator(this.variables, functions, this.program.enums);
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
this.nodeTitle = opts.startAt;
this.step();
}
public registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
this.evaluator.registerFunctions(functions);
}
public registerCommands(commands: Record<string, (args: unknown[], evaluator?: ExpressionEvaluator) => void | Promise<void>>) {
for(const key in commands){
this.commandHandler.register(key, (args, evaluator) => {
if(!evaluator) return;
commands[key].call(this, args.map(arg => evaluator.evaluateExpression(arg)));
});
}
};
/**
* Resolve a node title to an actual node (handling node groups).
*/
private resolveNode(title: string): IRNode {
const nodeOrGroup = this.program.nodes[title];
if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
// If it's a single node, return it
if (!("nodes" in nodeOrGroup)) {
this.currentNodeIndex = -1;
return nodeOrGroup as IRNode;
}
// It's a node group - select the first matching node based on when conditions
const group = nodeOrGroup as IRNodeGroup;
for (let i = 0; i < group.nodes.length; i++) {
const candidate = group.nodes[i];
if (this.evaluateWhenConditions(candidate.when, title, i)) {
this.currentNodeIndex = i;
// If "once" condition, mark as seen immediately
if (candidate.when?.includes("once")) {
this.markNodeGroupOnceSeen(title, i);
}
return candidate;
}
}
// No matching node found - throw error or return first? Docs suggest error if no match
throw new Error(`No matching node found in group ${title}`);
}
/**
* Evaluate when conditions for a node in a group.
*/
private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
if (!conditions || conditions.length === 0) {
// No when condition - available by default (but should not happen in groups)
return true;
}
// All conditions must be true (AND logic)
for (const condition of conditions) {
const trimmed = condition.trim();
if (trimmed === "once") {
// Check if this node has been visited once
const onceKey = `${nodeTitle}#${nodeIndex}`;
if (this.nodeGroupOnceSeen.has(onceKey)) {
return false; // Already seen once
}
// Will mark as seen when node is entered
continue;
}
if (trimmed === "always") {
// Always available
continue;
}
// Otherwise, treat as expression (e.g., "$has_sword")
if (!this.evaluator.evaluate(trimmed)) {
return false; // Condition failed
}
}
return true; // All conditions passed
}
/**
* Mark a node group node as seen (for "once" condition).
*/
private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
const onceKey = `${nodeTitle}#${nodeIndex}`;
this.nodeGroupOnceSeen.add(onceKey);
}
advance(optionIndex?: number) {
// If awaiting option selection, consume chosen option by pushing its block
if (this.currentResult?.type === "options") {
if (optionIndex == null) throw new Error("Option index required");
const options = this.pendingOptions;
if (!options) throw new Error("Invalid options state");
const chosen = options[optionIndex];
if (!chosen) throw new Error("Invalid option index");
// Push a block frame that we will resume across advances
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
this.pendingOptions = null;
if (this.resumeBlock()) return;
return;
}
// If we have a pending block, resume it first
if (this.resumeBlock()) return;
this.step();
}
/**
* Interpolate variables in text and update markup segments accordingly.
*/
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
const evaluateExpression = (expr: string): string => {
try {
const value = this.evaluator.evaluateExpression(expr.trim());
if (value === null || value === undefined) {
return "";
}
return String(value);
} catch {
return "";
}
};
if (!markup) {
const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
return { text: interpolated };
}
return this.interpolateWithMarkup(text, markup, evaluateExpression);
}
/**
* Interpolate text while preserving and updating markup segments.
*/
private interpolateWithMarkup(
text: string,
markup: MarkupParseResult,
evaluateExpression: (expr: string) => string
): { text: string; markup?: MarkupParseResult } {
const segments = markup.segments.filter((segment) => !segment.selfClosing);
const getWrappersAt = (index: number): MarkupWrapper[] => {
for (const segment of segments) {
if (segment.start <= index && index < segment.end) {
return segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
}
}
if (segments.length === 0) {
return [];
}
if (index > 0) {
return getWrappersAt(index - 1);
}
return segments[0].wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
};
const resultChars: string[] = [];
const newSegments: MarkupSegment[] = [];
let currentSegment: MarkupSegment | null = null;
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
const index = resultChars.length;
resultChars.push(char);
const wrappersCopy = wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
}));
if (currentSegment && this.wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
currentSegment.end = index + 1;
} else {
this.flushSegment(currentSegment, newSegments);
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
}
};
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
if (!value) {
this.flushSegment(currentSegment, newSegments);
currentSegment = null;
return;
}
for (const ch of value) {
appendCharWithWrappers(ch, wrappers);
}
};
let i = 0;
while (i < text.length) {
const char = text[i];
if (char === '{') {
const close = text.indexOf('}', i + 1);
if (close === -1) {
appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
i += 1;
continue;
}
const expr = text.slice(i + 1, close);
const evaluated = evaluateExpression(expr);
const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
appendStringWithWrappers(evaluated, wrappers);
i = close + 1;
continue;
}
appendCharWithWrappers(char, getWrappersAt(i));
i += 1;
}
this.flushSegment(currentSegment, newSegments);
const interpolatedText = resultChars.join('');
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
return { text: interpolatedText, markup: normalizedMarkup };
}
private wrappersEqual(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const wa = a[i];
const wb = b[i];
if (wa.name !== wb.name || wa.type !== wb.type) return false;
const keysA = Object.keys(wa.properties);
const keysB = Object.keys(wb.properties);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (wa.properties[key] !== wb.properties[key]) return false;
}
}
return true;
}
private flushSegment(segment: MarkupSegment | null, segments: MarkupSegment[]): void {
if (segment) {
segments.push(segment);
}
}
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
if (!result) return undefined;
if (result.segments.length === 0) {
return undefined;
}
const hasFormatting = result.segments.some(
(segment) => segment.wrappers.length > 0 || segment.selfClosing
);
if (!hasFormatting) {
return undefined;
}
return {
text: result.text,
segments: result.segments.map((segment) => ({
start: segment.start,
end: segment.end,
wrappers: segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
selfClosing: segment.selfClosing,
})),
};
}
private resumeBlock(): boolean {
const top = this.callStack[this.callStack.length - 1];
if (!top || top.kind !== "block") return false;
// Execute from stored idx until we emit one result or finish block
while (true) {
const ins = top.block[top.idx++];
if (!ins) {
// finished block; pop and continue main step
this.callStack.pop();
this.step();
return true;
}
switch (ins.op) {
case "line": {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
return true;
}
case "command": {
const parsed = parseCommand(ins.content);
// Execute command handler (errors are caught internally)
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
console.warn(`Command execution error: ${err}`);
});
if (this.handleCommand) this.handleCommand(ins.content, parsed);
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
return true;
}
case "options": {
const available = this.filterOptions(ins.options);
if (available.length === 0) {
continue;
}
this.pendingOptions = available;
this.emit({
type: "options",
options: available.map((o) => {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
return { text: interpolatedText, tags: o.tags, markup: interpolatedMarkup };
}),
isDialogueEnd: false,
});
return true;
}
case "if": {
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
if (branch) {
// Push nested block at current top position (resume after)
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
return this.resumeBlock();
}
break;
}
case "once": {
if (!this.onceSeen.has(ins.id)) {
this.onceSeen.add(ins.id);
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
return this.resumeBlock();
}
break;
}
case "jump": {
this.nodeTitle = ins.target;
this.ip = 0;
this.step();
return true;
}
case "detour": {
this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
this.nodeTitle = ins.target;
this.ip = 0;
this.step();
return true;
}
}
}
}
private step() {
while (true) {
const resolved = this.resolveNode(this.nodeTitle);
const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
const ins = currentNode.instructions[this.ip];
if (!ins) {
// Node ended
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
return;
}
this.ip++;
switch (ins.op) {
case "line": {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
return;
}
case "command": {
const parsed = parseCommand(ins.content);
// Execute command handler (errors are caught internally)
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
console.warn(`Command execution error: ${err}`);
});
if (this.handleCommand) this.handleCommand(ins.content, parsed);
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
return;
}
case "jump": {
// Exiting current node due to jump
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
this.nodeTitle = ins.target;
this.ip = 0;
this.currentNodeIndex = -1; // Reset node index for new resolution
// resolveNode will handle node groups
continue;
}
case "detour": {
// Save return position, jump to target node, return when it ends
this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
this.nodeTitle = ins.target;
this.ip = 0;
this.currentNodeIndex = -1; // Reset node index for new resolution
// resolveNode will handle node groups
continue;
}
case "return": {
const top = this.callStack.pop();
if (!top) {
console.warn("Return called with empty call stack");
continue;
}
this.nodeTitle = top.title;
this.ip = top.ip;
this.currentNodeIndex = -1; // Reset node index for new resolution
continue;
}
case "options": {
const available = this.filterOptions(ins.options);
if (available.length === 0) {
continue;
}
this.pendingOptions = available;
this.emit({
type: "options",
options: available.map((o) => {
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
return { text: interpolatedText, tags: o.tags, css: o.css, markup: interpolatedMarkup };
}),
nodeCss: resolved.css,
scene: resolved.scene,
isDialogueEnd: this.lookaheadIsEnd(),
});
return;
}
case "if": {
const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
if (branch) {
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
if (this.resumeBlock()) return;
}
break;
}
case "once": {
if (!this.onceSeen.has(ins.id)) {
this.onceSeen.add(ins.id);
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
if (this.resumeBlock()) return;
}
break;
}
}
}
}
private filterOptions(options: CompiledOption[]): CompiledOption[] {
const available: CompiledOption[] = [];
for (const option of options) {
if (!option.condition) {
available.push(option);
continue;
}
try {
if (this.evaluator.evaluate(option.condition)) {
available.push(option);
}
} catch (err) {
console.warn(`Option condition evaluation error: ${err}`);
// Treat errors as false conditions
}
}
return available;
}
private lookaheadIsEnd(): boolean {
// Check if current node has more emit-worthy instructions ahead
const node = this.resolveNode(this.nodeTitle);
for (let k = this.ip; k < node.instructions.length; k++) {
const op = node.instructions[k]?.op;
if (!op) break;
// These instructions produce output or control flow changes
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
if (op === "jump" || op === "detour") return false;
}
return true;
}
private emit(res: RuntimeResult) {
this.currentResult = res;
this.history.push(res);
if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
this.storyEnded = true;
if (this.onStoryEnd) {
// Create a readonly copy of the variables
const variablesCopy = Object.freeze({ ...this.variables });
this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
}
}
// If we ended a detour node, return to caller after emitting last result
// Position is restored here, but we wait for next advance() to continue
if (res.isDialogueEnd && this.callStack.length > 0) {
const frame = this.callStack.pop()!;
this.nodeTitle = frame.title;
this.ip = frame.ip;
}
}
/**
* Get the current variable store (read-only view).
*/
getVariables(): Readonly<Record<string, unknown>> {
return { ...this.variables };
}
/**
* Get variable value.
*/
getVariable(name: string): unknown {
if(this.evaluator.isSmartVariable(name))
return this.evaluator.evaluateExpression(`$${name}`);
return this.variables[name];
}
/**
* Set variable value.
*/
setVariable(name: string, value: unknown): void {
this.variables[name] = value;
this.evaluator.setVariable(name, value);
}
setSmartVariable(name: string, expression: string): void {
this.evaluator.setSmartVariable(name, expression);
}
}