UNPKG

yarn-spinner-runner-ts

Version:

TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)

708 lines (663 loc) 26 kB
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir"; import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types.js"; import type { RuntimeResult } from "./results.js"; import { ExpressionEvaluator } from "./evaluator.js"; import { CommandHandler, parseCommand } from "./commands.js"; 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; } 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[]; }; export class YarnRunner { private readonly program: IRProgram; private readonly variables: Record<string, unknown>; private readonly functions: Record<string, (...args: unknown[]) => unknown>; private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void; private readonly commandHandler: CommandHandler; private readonly evaluator: ExpressionEvaluator; private readonly onceSeen = globalOnceSeen; private readonly onStoryEnd?: RunnerOptions["onStoryEnd"]; private storyEnded = false; private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen; 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: Array< | ({ title: string; ip: number } & { kind: "detour" }) | ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" }) > = []; 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; } } this.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, this.functions, this.program.enums); this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables); this.nodeTitle = opts.startAt; this.step(); } /** * Get the current node title (may resolve to a node group). */ getCurrentNodeTitle(): string { return this.nodeTitle; } /** * 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(); } 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 }; } 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 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 flushSegment = () => { if (currentSegment) { newSegments.push(currentSegment); currentSegment = 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 && wrappersEqual(currentSegment.wrappers, wrappersCopy)) { currentSegment.end = index + 1; } else { flushSegment(); currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy }; } }; const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => { if (!value) { flushSegment(); 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; } flushSegment(); const interpolatedText = resultChars.join(''); const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments }); return { text: interpolatedText, markup: normalizedMarkup }; } 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": { try { const parsed = parseCommand(ins.content); this.commandHandler.execute(parsed, this.evaluator).catch(() => {}); if (this.handleCommand) this.handleCommand(ins.content, parsed); } catch { if (this.handleCommand) this.handleCommand(ins.content); } 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": { try { const parsed = parseCommand(ins.content); this.commandHandler.execute(parsed, this.evaluator).catch(() => {}); if (this.handleCommand) this.handleCommand(ins.content, parsed); } catch { if (this.handleCommand) this.handleCommand(ins.content); } 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 "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 executeBlock(block: { title: string; instructions: IRInstruction[] }) { // Execute instructions of block, then resume const saved = { title: this.nodeTitle, ip: this.ip } as const; this.nodeTitle = block.title; const tempIpStart = 0; const tempNode = { title: block.title, instructions: block.instructions } as const; // Use a temporary node context const restore = () => { this.nodeTitle = saved.title; this.ip = saved.ip; }; // Step through block, emitting first result let idx = tempIpStart; while (true) { const ins = tempNode.instructions[idx++]; if (!ins) break; 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, markup: interpolatedMarkup, isDialogueEnd: false }); restore(); return; } case "command": try { const parsed = parseCommand(ins.content); this.commandHandler.execute(parsed, this.evaluator).catch(() => {}); if (this.handleCommand) this.handleCommand(ins.content, parsed); } catch { if (this.handleCommand) this.handleCommand(ins.content); } this.emit({ type: "command", command: ins.content, isDialogueEnd: false }); restore(); return; 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, markup: interpolatedMarkup }; }), isDialogueEnd: false, }); // Maintain context that options belong to main node at ip-1 restore(); return; } case "if": { const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true)); if (branch) { // enqueue nested block and resume from main context this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 }); restore(); if (this.resumeBlock()) return; 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 }); restore(); if (this.resumeBlock()) return; return; } break; } case "jump": { this.nodeTitle = ins.target; this.ip = 0; this.step(); return; } case "detour": { this.callStack.push({ kind: "detour", title: saved.title, ip: saved.ip }); this.nodeTitle = ins.target; this.ip = 0; this.step(); return; } } } // Block produced no output; resume restore(); this.step(); } 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 { // Treat errors as false conditions } } return available; } private lookaheadIsEnd(): boolean { // Check if current node has more emit-worthy instructions 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; if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false; if (op === "jump" || op === "detour") return false; } // Node is ending - mark as end (will trigger detour return if callStack exists) 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 { return this.variables[name]; } /** * Set variable value. */ setVariable(name: string, value: unknown): void { this.variables[name] = value; this.evaluator.setVariable(name, value); } }