commit 9942bd9a7f5f942302ba9a1c2c92604ff7d42346 Author: hypercross Date: Tue Apr 14 14:57:03 2026 +0800 init: yarn-spinner diff --git a/src/yarn-spinner/compile/compiler.ts b/src/yarn-spinner/compile/compiler.ts new file mode 100644 index 0000000..dc537e1 --- /dev/null +++ b/src/yarn-spinner/compile/compiler.ts @@ -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(); + 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; +} + diff --git a/src/yarn-spinner/compile/ir.ts b/src/yarn-spinner/compile/ir.ts new file mode 100644 index 0000000..f6e9c12 --- /dev/null +++ b/src/yarn-spinner/compile/ir.ts @@ -0,0 +1,29 @@ +import type { MarkupParseResult } from "../markup/types"; +export type IRProgram = { + enums: Record; // enum name -> cases + nodes: Record; // 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[] }; + diff --git a/src/yarn-spinner/index.ts b/src/yarn-spinner/index.ts new file mode 100644 index 0000000..65b1b74 --- /dev/null +++ b/src/yarn-spinner/index.ts @@ -0,0 +1,3 @@ +export {parseYarn} from './parse/parser'; +export {compile} from './compile/compiler'; +export {YarnRunner} from "./runtime/runner"; \ No newline at end of file diff --git a/src/yarn-spinner/markup/parser.ts b/src/yarn-spinner/markup/parser.ts new file mode 100644 index 0000000..93c7641 --- /dev/null +++ b/src/yarn-spinner/markup/parser.ts @@ -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; + originalText: string; +} + +interface ParsedTag { + kind: "open" | "close" | "self"; + name: string; + properties: Record; +} + +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 = {}; + 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), + }; +} diff --git a/src/yarn-spinner/markup/types.ts b/src/yarn-spinner/markup/types.ts new file mode 100644 index 0000000..bab3cbf --- /dev/null +++ b/src/yarn-spinner/markup/types.ts @@ -0,0 +1,21 @@ +export type MarkupValue = string | number | boolean; + +export type MarkupWrapperType = "default" | "custom"; + +export interface MarkupWrapper { + name: string; + type: MarkupWrapperType; + properties: Record; +} + +export interface MarkupSegment { + start: number; + end: number; + wrappers: MarkupWrapper[]; + selfClosing?: boolean; +} + +export interface MarkupParseResult { + text: string; + segments: MarkupSegment[]; +} diff --git a/src/yarn-spinner/model/ast.ts b/src/yarn-spinner/model/ast.ts new file mode 100644 index 0000000..2a8dd79 --- /dev/null +++ b/src/yarn-spinner/model/ast.ts @@ -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[]; +} + diff --git a/src/yarn-spinner/parse/lexer.ts b/src/yarn-spinner/parse/lexer.ts new file mode 100644 index 0000000..d4a95c8 --- /dev/null +++ b/src/yarn-spinner/parse/lexer.ts @@ -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; +} + diff --git a/src/yarn-spinner/parse/parser.ts b/src/yarn-spinner/parse/parser.ts new file mode 100644 index 0000000..6f5816b --- /dev/null +++ b/src/yarn-spinner/parse/parser.ts @@ -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 = {}; + 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 <>; expect body under INDENT then <> as COMMAND + let body: Statement[] = []; + if (this.at("INDENT")) { + this.take("INDENT"); + body = this.parseStatementsUntil("DEDENT"); + this.take("DEDENT"); + } else { + // Alternatively, body until explicit <> 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 <> + 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] + } + } +} + diff --git a/src/yarn-spinner/runtime/commands.ts b/src/yarn-spinner/runtime/commands.ts new file mode 100644 index 0000000..0ab2d53 --- /dev/null +++ b/src/yarn-spinner/runtime/commands.ts @@ -0,0 +1,199 @@ +/** + * Command parser and handler utilities for Yarn Spinner commands. + * Commands like <> or <> + */ + +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 void | Promise>(); + private variables: Record; + + constructor(variables: Record = {}) { + this.variables = variables; + this.registerBuiltins(); + } + + /** + * Register a command handler. + */ + register(name: string, handler: (args: string[], evaluator?: Evaluator) => void | Promise): void { + this.handlers.set(name.toLowerCase(), handler); + } + + /** + * Execute a parsed command. + */ + async execute(parsed: ParsedCommand, evaluator?: Evaluator): Promise { + 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 { + // <> or <> or <> + 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); + }); + + // <> + 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); + } + }); + + // <> - no-op, just a marker + this.register("stop", () => { + // Dialogue stop marker + }); + } +} diff --git a/src/yarn-spinner/runtime/evaluator.ts b/src/yarn-spinner/runtime/evaluator.ts new file mode 100644 index 0000000..e32e80d --- /dev/null +++ b/src/yarn-spinner/runtime/evaluator.ts @@ -0,0 +1,558 @@ +/** + * Safe expression evaluator for Yarn Spinner conditions. + * Supports variables, functions, comparisons, and logical operators. + */ +export class ExpressionEvaluator { + private smartVariables: Record = {}; // variable name -> expression + + constructor( + private variables: Record = {}, + private functions: Record unknown> = {}, + private enums: Record = {} // 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)[key], (b as Record)[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 unknown>){ + this.functions = { + ...this.functions, + ...functions + } + } +} diff --git a/src/yarn-spinner/runtime/results.ts b/src/yarn-spinner/runtime/results.ts new file mode 100644 index 0000000..e0b9323 --- /dev/null +++ b/src/yarn-spinner/runtime/results.ts @@ -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; + diff --git a/src/yarn-spinner/runtime/runner.ts b/src/yarn-spinner/runtime/runner.ts new file mode 100644 index 0000000..b7f96d9 --- /dev/null +++ b/src/yarn-spinner/runtime/runner.ts @@ -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; + functions?: Record unknown>; + handleCommand?: (command: string, parsed?: ReturnType) => void; + commandHandler?: CommandHandler; + onStoryEnd?: (payload: { variables: Readonly>; 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(); +const globalNodeGroupOnceSeen = new Set(); // 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; + private readonly handleCommand?: (command: string, parsed?: ReturnType) => void; + private readonly commandHandler: CommandHandler; + private readonly evaluator: ExpressionEvaluator; + private readonly onceSeen: Set; + private readonly nodeGroupOnceSeen: Set; + private readonly onStoryEnd?: RunnerOptions["onStoryEnd"]; + private storyEnded = false; + private readonly visitCounts: Record = {}; + 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(); + this.nodeGroupOnceSeen = new Set(); + } 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 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 unknown>){ + this.evaluator.registerFunctions(functions); + } + + public registerCommands(commands: Record void | Promise>) { + 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> { + 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); + } +} +