From 163aa8c783c4ad261bc45ef1a65b5b71bca2cc52 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 14 Apr 2026 16:35:18 +0800 Subject: [PATCH] refactor: move yarn-spinner-loader to external dependency --- package-lock.json | 41 +- package.json | 3 +- src/components/md-yarn-spinner.tsx | 2 +- src/components/stores/yarnStore.ts | 5 +- src/yarn-spinner/compile/compiler.ts | 136 ------ src/yarn-spinner/compile/ir.ts | 29 -- src/yarn-spinner/index.ts | 3 - src/yarn-spinner/markup/parser.ts | 381 --------------- src/yarn-spinner/markup/types.ts | 21 - src/yarn-spinner/model/ast.ts | 103 ---- src/yarn-spinner/parse/lexer.ts | 107 ----- src/yarn-spinner/parse/parser.ts | 540 --------------------- src/yarn-spinner/runtime/commands.ts | 199 -------- src/yarn-spinner/runtime/evaluator.ts | 558 ---------------------- src/yarn-spinner/runtime/results.ts | 41 -- src/yarn-spinner/runtime/runner.ts | 662 -------------------------- 16 files changed, 45 insertions(+), 2786 deletions(-) delete mode 100644 src/yarn-spinner/compile/compiler.ts delete mode 100644 src/yarn-spinner/compile/ir.ts delete mode 100644 src/yarn-spinner/index.ts delete mode 100644 src/yarn-spinner/markup/parser.ts delete mode 100644 src/yarn-spinner/markup/types.ts delete mode 100644 src/yarn-spinner/model/ast.ts delete mode 100644 src/yarn-spinner/parse/lexer.ts delete mode 100644 src/yarn-spinner/parse/parser.ts delete mode 100644 src/yarn-spinner/runtime/commands.ts delete mode 100644 src/yarn-spinner/runtime/evaluator.ts delete mode 100644 src/yarn-spinner/runtime/results.ts delete mode 100644 src/yarn-spinner/runtime/runner.ts diff --git a/package-lock.json b/package-lock.json index 7c99144..4f22d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "solid-element": "^1.9.1", "solid-js": "^1.9.3", "three": "^0.183.2", - "three-3mf-exporter": "^45.1.0" + "three-3mf-exporter": "^45.1.0", + "yarn-spinner-loader": "file:../yarn-spinner-loader" }, "bin": { "ttrpg": "dist/cli/index.js" @@ -49,6 +50,40 @@ "typescript": "^5.9.3" } }, + "../yarn-spinner-loader": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "fast-glob": "^3.3.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^3.0.4" + }, + "peerDependencies": { + "esbuild": ">=0.20.0", + "rollup": ">=4.0.0", + "vite": ">=5.0.0", + "webpack": ">=5.0.0" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -9938,6 +9973,10 @@ "node": ">=12" } }, + "node_modules/yarn-spinner-loader": { + "resolved": "../yarn-spinner-loader", + "link": true + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 5d855e8..df6ab68 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "solid-element": "^1.9.1", "solid-js": "^1.9.3", "three": "^0.183.2", - "three-3mf-exporter": "^45.1.0" + "three-3mf-exporter": "^45.1.0", + "yarn-spinner-loader": "file:../yarn-spinner-loader" }, "devDependencies": { "@image-tracer-ts/core": "^1.0.2", diff --git a/src/components/md-yarn-spinner.tsx b/src/components/md-yarn-spinner.tsx index 0f2d5ec..00b4412 100644 --- a/src/components/md-yarn-spinner.tsx +++ b/src/components/md-yarn-spinner.tsx @@ -1,6 +1,6 @@ import { customElement, noShadowDOM } from 'solid-element'; import { For, Show, createEffect } from 'solid-js'; -import type {TextResult, RuntimeResult, OptionsResult} from '../yarn-spinner/runtime/results'; +import type {TextResult, RuntimeResult, OptionsResult} from 'yarn-spinner-loader'; import { createYarnStore } from './stores/yarnStore'; export interface YarnSpinnerProps { diff --git a/src/components/stores/yarnStore.ts b/src/components/stores/yarnStore.ts index e61e9b6..f9a6c36 100644 --- a/src/components/stores/yarnStore.ts +++ b/src/components/stores/yarnStore.ts @@ -1,9 +1,8 @@ import {createEffect, createResource } from "solid-js"; -import type {OptionsResult, RuntimeResult} from "../../yarn-spinner/runtime/results"; -import {compile, parseYarn, YarnRunner} from "../../yarn-spinner"; +import type {OptionsResult, RuntimeResult} from "yarn-spinner-loader"; +import {compile, parseYarn, YarnRunner, RunnerOptions} from "yarn-spinner-loader"; import {loadElementSrc, resolvePath} from "../utils/path"; import {createStore} from "solid-js/store"; -import {RunnerOptions} from "../../yarn-spinner/runtime/runner"; import {getTtrpgFunctions} from "./ttrpgRunner"; import { getIndexedData } from "../../data-loader/file-index"; diff --git a/src/yarn-spinner/compile/compiler.ts b/src/yarn-spinner/compile/compiler.ts deleted file mode 100644 index dc537e1..0000000 --- a/src/yarn-spinner/compile/compiler.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index f6e9c12..0000000 --- a/src/yarn-spinner/compile/ir.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 65b1b74..0000000 --- a/src/yarn-spinner/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 93c7641..0000000 --- a/src/yarn-spinner/markup/parser.ts +++ /dev/null @@ -1,381 +0,0 @@ -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 deleted file mode 100644 index bab3cbf..0000000 --- a/src/yarn-spinner/markup/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 2a8dd79..0000000 --- a/src/yarn-spinner/model/ast.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index d4a95c8..0000000 --- a/src/yarn-spinner/parse/lexer.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 6f5816b..0000000 --- a/src/yarn-spinner/parse/parser.ts +++ /dev/null @@ -1,540 +0,0 @@ -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 deleted file mode 100644 index 0ab2d53..0000000 --- a/src/yarn-spinner/runtime/commands.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * 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 deleted file mode 100644 index e32e80d..0000000 --- a/src/yarn-spinner/runtime/evaluator.ts +++ /dev/null @@ -1,558 +0,0 @@ -/** - * 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 deleted file mode 100644 index e0b9323..0000000 --- a/src/yarn-spinner/runtime/results.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index b7f96d9..0000000 --- a/src/yarn-spinner/runtime/runner.ts +++ /dev/null @@ -1,662 +0,0 @@ -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); - } -} -