init: yarn-spinner
This commit is contained in:
commit
9942bd9a7f
|
|
@ -0,0 +1,136 @@
|
|||
import type {YarnDocument, Statement, Line, Option, YarnNode} from "../model/ast";
|
||||
import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir";
|
||||
|
||||
export interface CompileOptions {
|
||||
generateOnceIds?: (ctx: { node: string; index: number }) => string;
|
||||
}
|
||||
|
||||
export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram {
|
||||
const program: IRProgram = { enums: {}, nodes: {} };
|
||||
// Store enum definitions
|
||||
for (const enumDef of doc.enums) {
|
||||
program.enums[enumDef.name] = enumDef.cases;
|
||||
}
|
||||
const genOnce = opts.generateOnceIds ?? ((x) => `${x.node}#once#${x.index}`);
|
||||
let globalLineCounter = 0;
|
||||
|
||||
function ensureLineId(tags?: string[]): string[] | undefined {
|
||||
const t = tags ? [...tags] : [];
|
||||
if (!t.some((x) => x.startsWith("line:"))) {
|
||||
t.push(`line:${(globalLineCounter++).toString(16)}`);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// Group nodes by title to handle node groups
|
||||
const nodesByTitle = new Map<string, typeof doc.nodes>();
|
||||
for (const node of doc.nodes) {
|
||||
if (!nodesByTitle.has(node.title)) {
|
||||
nodesByTitle.set(node.title, []);
|
||||
}
|
||||
nodesByTitle.get(node.title)!.push(node);
|
||||
}
|
||||
|
||||
let onceCounter = 0;
|
||||
function emitBlock(stmts: Statement[], node: YarnNode): IRInstruction[] {
|
||||
const block: IRInstruction[] = [];
|
||||
for (const s of stmts) {
|
||||
switch (s.type) {
|
||||
case "Line":
|
||||
{
|
||||
const line = s as Line;
|
||||
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
|
||||
}
|
||||
break;
|
||||
case "Command":
|
||||
block.push({ op: "command", content: s.content });
|
||||
break;
|
||||
case "Jump":
|
||||
block.push({ op: "jump", target: s.target });
|
||||
break;
|
||||
case "Detour":
|
||||
block.push({ op: "detour", target: s.target });
|
||||
break;
|
||||
case "Return":
|
||||
block.push({ op: "return" });
|
||||
break;
|
||||
case "OptionGroup": {
|
||||
// Add #lastline tag to the most recent line, if present
|
||||
for (let i = block.length - 1; i >= 0; i--) {
|
||||
const ins = block[i];
|
||||
if (ins.op === "line") {
|
||||
const tags = new Set(ins.tags ?? []);
|
||||
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
|
||||
tags.add("lastline");
|
||||
}
|
||||
ins.tags = Array.from(tags);
|
||||
break;
|
||||
}
|
||||
if (ins.op !== "command") break; // stop if non-line non-command before options
|
||||
}
|
||||
block.push({
|
||||
op: "options",
|
||||
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body, node) })),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "If":
|
||||
block.push({
|
||||
op: "if",
|
||||
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body, node) })),
|
||||
});
|
||||
break;
|
||||
case "Once":
|
||||
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body, node) });
|
||||
break;
|
||||
case "Enum":
|
||||
// Enums are metadata, skip during compilation (already stored in program.enums)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
for (const [title, nodesWithSameTitle] of nodesByTitle) {
|
||||
// If only one node with this title, treat as regular node
|
||||
if (nodesWithSameTitle.length === 1) {
|
||||
const node = nodesWithSameTitle[0];
|
||||
const instructions: IRInstruction[] = [];
|
||||
|
||||
onceCounter = 0;
|
||||
instructions.push(...emitBlock(node.body, node));
|
||||
const irNode: IRNode = {
|
||||
title: node.title,
|
||||
instructions,
|
||||
when: node.when,
|
||||
css: (node as any).css,
|
||||
scene: node.headers.scene?.trim() || undefined
|
||||
};
|
||||
program.nodes[node.title] = irNode;
|
||||
} else {
|
||||
// Multiple nodes with same title - create node group
|
||||
const groupNodes: IRNode[] = [];
|
||||
for (const node of nodesWithSameTitle) {
|
||||
const instructions: IRInstruction[] = [];
|
||||
|
||||
onceCounter = 0;
|
||||
instructions.push(...emitBlock(node.body, node));
|
||||
groupNodes.push({
|
||||
title: node.title,
|
||||
instructions,
|
||||
when: node.when,
|
||||
css: (node as any).css,
|
||||
scene: node.headers.scene?.trim() || undefined
|
||||
});
|
||||
}
|
||||
const group: IRNodeGroup = {
|
||||
title,
|
||||
nodes: groupNodes
|
||||
};
|
||||
program.nodes[title] = group;
|
||||
}
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { MarkupParseResult } from "../markup/types";
|
||||
export type IRProgram = {
|
||||
enums: Record<string, string[]>; // enum name -> cases
|
||||
nodes: Record<string, IRNode | IRNodeGroup>; // can be single node or group
|
||||
};
|
||||
|
||||
export type IRNode = {
|
||||
title: string;
|
||||
instructions: IRInstruction[];
|
||||
when?: string[]; // Array of when conditions
|
||||
css?: string;
|
||||
scene?: string; // Scene name from node header
|
||||
};
|
||||
|
||||
export type IRNodeGroup = {
|
||||
title: string;
|
||||
nodes: IRNode[]; // Multiple nodes with same title, different when conditions
|
||||
};
|
||||
|
||||
export type IRInstruction =
|
||||
| { op: "line"; speaker?: string; text: string; tags?: string[]; markup?: MarkupParseResult }
|
||||
| { op: "command"; content: string }
|
||||
| { op: "jump"; target: string }
|
||||
| { op: "detour"; target: string }
|
||||
| { op: "return"; }
|
||||
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> }
|
||||
| { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
|
||||
| { op: "once"; id: string; block: IRInstruction[] };
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export {parseYarn} from './parse/parser';
|
||||
export {compile} from './compile/compiler';
|
||||
export {YarnRunner} from "./runtime/runner";
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types";
|
||||
|
||||
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
|
||||
const SELF_CLOSING_TAGS = new Set(["br"]);
|
||||
|
||||
interface StackEntry {
|
||||
name: string;
|
||||
type: MarkupWrapper["type"];
|
||||
properties: Record<string, MarkupValue>;
|
||||
originalText: string;
|
||||
}
|
||||
|
||||
interface ParsedTag {
|
||||
kind: "open" | "close" | "self";
|
||||
name: string;
|
||||
properties: Record<string, MarkupValue>;
|
||||
}
|
||||
|
||||
const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
|
||||
const ATTRIBUTE_REGEX =
|
||||
/^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
|
||||
|
||||
export function parseMarkup(input: string): MarkupParseResult {
|
||||
const segments: MarkupSegment[] = [];
|
||||
const stack: StackEntry[] = [];
|
||||
const chars: string[] = [];
|
||||
let currentSegment: MarkupSegment | null = null;
|
||||
let nomarkupDepth = 0;
|
||||
|
||||
const pushSegment = (segment: MarkupSegment) => {
|
||||
if (segment.selfClosing || segment.end > segment.start) {
|
||||
segments.push(segment);
|
||||
}
|
||||
};
|
||||
|
||||
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const wa = a[i];
|
||||
const wb = b[i];
|
||||
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
||||
const keysA = Object.keys(wa.properties);
|
||||
const keysB = Object.keys(wb.properties);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (wa.properties[key] !== wb.properties[key]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const flushCurrentSegment = () => {
|
||||
if (currentSegment) {
|
||||
segments.push(currentSegment);
|
||||
currentSegment = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cloneWrappers = (): MarkupWrapper[] =>
|
||||
stack.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
properties: { ...entry.properties },
|
||||
}));
|
||||
|
||||
const appendChar = (char: string) => {
|
||||
const index = chars.length;
|
||||
chars.push(char);
|
||||
const wrappers = cloneWrappers();
|
||||
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
|
||||
currentSegment.end = index + 1;
|
||||
} else {
|
||||
flushCurrentSegment();
|
||||
currentSegment = {
|
||||
start: index,
|
||||
end: index + 1,
|
||||
wrappers,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const appendLiteral = (literal: string) => {
|
||||
for (const ch of literal) {
|
||||
appendChar(ch);
|
||||
}
|
||||
};
|
||||
|
||||
const parseTag = (contentRaw: string): ParsedTag | null => {
|
||||
let content = contentRaw.trim();
|
||||
if (!content) return null;
|
||||
|
||||
if (content.startsWith("/")) {
|
||||
const name = content.slice(1).trim().toLowerCase();
|
||||
if (!name) return null;
|
||||
return { kind: "close", name, properties: {} };
|
||||
}
|
||||
|
||||
let kind: ParsedTag["kind"] = "open";
|
||||
if (content.endsWith("/")) {
|
||||
content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
|
||||
if (content.endsWith("/")) {
|
||||
content = content.slice(0, -1).trim();
|
||||
}
|
||||
kind = "self";
|
||||
}
|
||||
|
||||
const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1].toLowerCase();
|
||||
let rest = content.slice(nameMatch[0].length).trim();
|
||||
|
||||
const properties: Record<string, MarkupValue> = {};
|
||||
while (rest.length > 0) {
|
||||
const attrMatch = rest.match(ATTRIBUTE_REGEX);
|
||||
if (!attrMatch) {
|
||||
break;
|
||||
}
|
||||
const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
|
||||
const key = keyRaw.toLowerCase();
|
||||
let value: MarkupValue = true;
|
||||
const rawValue = doubleQuoted ?? singleQuoted ?? bare;
|
||||
if (rawValue !== undefined) {
|
||||
value = parseAttributeValue(rawValue);
|
||||
}
|
||||
properties[key] = value;
|
||||
rest = rest.slice(attrMatch[0].length).trim();
|
||||
}
|
||||
|
||||
const finalKind: ParsedTag["kind"] = kind === "self" || SELF_CLOSING_TAGS.has(name) ? "self" : kind;
|
||||
return { kind: finalKind, name, properties };
|
||||
};
|
||||
|
||||
const parseAttributeValue = (raw: string): MarkupValue => {
|
||||
const trimmed = raw.trim();
|
||||
if (/^(true|false)$/i.test(trimmed)) {
|
||||
return /^true$/i.test(trimmed);
|
||||
}
|
||||
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const handleSelfClosing = (tag: ParsedTag) => {
|
||||
const wrapper: MarkupWrapper = {
|
||||
name: tag.name,
|
||||
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
|
||||
properties: tag.properties,
|
||||
};
|
||||
const position = chars.length;
|
||||
pushSegment({
|
||||
start: position,
|
||||
end: position,
|
||||
wrappers: [wrapper],
|
||||
selfClosing: true,
|
||||
});
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
if (char === "\\" && i + 1 < input.length) {
|
||||
const next = input[i + 1];
|
||||
if (next === "[" || next === "]" || next === "\\") {
|
||||
appendChar(next);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (char === "[") {
|
||||
const closeIndex = findClosingBracket(input, i + 1);
|
||||
if (closeIndex === -1) {
|
||||
appendChar(char);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const content = input.slice(i + 1, closeIndex);
|
||||
const originalText = input.slice(i, closeIndex + 1);
|
||||
|
||||
const parsed = parseTag(content);
|
||||
if (!parsed) {
|
||||
appendLiteral(originalText);
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.name === "nomarkup") {
|
||||
if (parsed.kind === "open") {
|
||||
nomarkupDepth += 1;
|
||||
} else if (parsed.kind === "close" && nomarkupDepth > 0) {
|
||||
nomarkupDepth -= 1;
|
||||
}
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nomarkupDepth > 0) {
|
||||
appendLiteral(originalText);
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "open") {
|
||||
const entry: StackEntry = {
|
||||
name: parsed.name,
|
||||
type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
|
||||
properties: parsed.properties,
|
||||
originalText,
|
||||
};
|
||||
stack.push(entry);
|
||||
flushCurrentSegment();
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "self") {
|
||||
handleSelfClosing(parsed);
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// closing tag
|
||||
if (stack.length === 0) {
|
||||
if (SELF_CLOSING_TAGS.has(parsed.name)) {
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
appendLiteral(originalText);
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
const top = stack[stack.length - 1];
|
||||
if (top.name === parsed.name) {
|
||||
flushCurrentSegment();
|
||||
stack.pop();
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
if (SELF_CLOSING_TAGS.has(parsed.name)) {
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
// mismatched closing; treat as literal
|
||||
appendLiteral(originalText);
|
||||
i = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
appendChar(char);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flushCurrentSegment();
|
||||
|
||||
// If any tags remain open, treat them as literal text appended at end
|
||||
while (stack.length > 0) {
|
||||
const entry = stack.pop()!;
|
||||
appendLiteral(entry.originalText);
|
||||
}
|
||||
flushCurrentSegment();
|
||||
|
||||
const text = chars.join("");
|
||||
return {
|
||||
text,
|
||||
segments: mergeSegments(segments, text.length),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSegments(segments: MarkupSegment[], textLength: number): MarkupSegment[] {
|
||||
const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
const merged: MarkupSegment[] = [];
|
||||
let last: MarkupSegment | null = null;
|
||||
|
||||
for (const seg of sorted) {
|
||||
if (seg.start === seg.end && !seg.selfClosing) {
|
||||
continue;
|
||||
}
|
||||
if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
|
||||
last.end = seg.end;
|
||||
} else {
|
||||
last = {
|
||||
start: seg.start,
|
||||
end: seg.end,
|
||||
wrappers: seg.wrappers,
|
||||
selfClosing: seg.selfClosing,
|
||||
};
|
||||
merged.push(last);
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.length === 0 && textLength > 0) {
|
||||
merged.push({
|
||||
start: 0,
|
||||
end: textLength,
|
||||
wrappers: [],
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function wrappersMatch(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].name !== b[i].name || a[i].type !== b[i].type) return false;
|
||||
const keysA = Object.keys(a[i].properties);
|
||||
const keysB = Object.keys(b[i].properties);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (a[i].properties[key] !== b[i].properties[key]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function findClosingBracket(text: string, start: number): number {
|
||||
for (let i = start; i < text.length; i++) {
|
||||
if (text[i] === "]") {
|
||||
let backslashCount = 0;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && text[j] === "\\") {
|
||||
backslashCount++;
|
||||
j--;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function sliceMarkup(result: MarkupParseResult, start: number, end?: number): MarkupParseResult {
|
||||
const textLength = result.text.length;
|
||||
const sliceStart = Math.max(0, Math.min(start, textLength));
|
||||
const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
|
||||
const slicedSegments: MarkupSegment[] = [];
|
||||
|
||||
for (const seg of result.segments) {
|
||||
const segStart = Math.max(seg.start, sliceStart);
|
||||
const segEnd = Math.min(seg.end, sliceEnd);
|
||||
if (seg.selfClosing) {
|
||||
if (segStart >= sliceStart && segStart <= sliceEnd) {
|
||||
slicedSegments.push({
|
||||
start: segStart - sliceStart,
|
||||
end: segStart - sliceStart,
|
||||
wrappers: seg.wrappers,
|
||||
selfClosing: true,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (segEnd <= segStart) continue;
|
||||
slicedSegments.push({
|
||||
start: segStart - sliceStart,
|
||||
end: segEnd - sliceStart,
|
||||
wrappers: seg.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
|
||||
slicedSegments.push({
|
||||
start: 0,
|
||||
end: sliceEnd - sliceStart,
|
||||
wrappers: [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: result.text.slice(sliceStart, sliceEnd),
|
||||
segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export type MarkupValue = string | number | boolean;
|
||||
|
||||
export type MarkupWrapperType = "default" | "custom";
|
||||
|
||||
export interface MarkupWrapper {
|
||||
name: string;
|
||||
type: MarkupWrapperType;
|
||||
properties: Record<string, MarkupValue>;
|
||||
}
|
||||
|
||||
export interface MarkupSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
wrappers: MarkupWrapper[];
|
||||
selfClosing?: boolean;
|
||||
}
|
||||
|
||||
export interface MarkupParseResult {
|
||||
text: string;
|
||||
segments: MarkupSegment[];
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
export type Position = { line: number; column: number };
|
||||
|
||||
export interface NodeHeaderMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface YarnDocument {
|
||||
type: "Document";
|
||||
enums: EnumDefinition[];
|
||||
nodes: YarnNode[];
|
||||
}
|
||||
|
||||
export interface EnumDefinition {
|
||||
type: "Enum";
|
||||
name: string;
|
||||
cases: string[];
|
||||
}
|
||||
|
||||
export interface YarnNode {
|
||||
type: "Node";
|
||||
title: string;
|
||||
headers: NodeHeaderMap;
|
||||
nodeTags?: string[];
|
||||
when?: string[]; // Array of when conditions (can be "once", "always", or expression like "$has_sword")
|
||||
css?: string; // Custom CSS style for node
|
||||
body: Statement[];
|
||||
}
|
||||
|
||||
export type Statement =
|
||||
| Line
|
||||
| Command
|
||||
| OptionGroup
|
||||
| IfBlock
|
||||
| OnceBlock
|
||||
| Jump
|
||||
| Detour
|
||||
| Return
|
||||
| EnumBlock;
|
||||
|
||||
import type { MarkupParseResult } from "../markup/types.js";
|
||||
|
||||
export interface Line {
|
||||
type: "Line";
|
||||
speaker?: string;
|
||||
text: string;
|
||||
tags?: string[];
|
||||
markup?: MarkupParseResult;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
type: "Command";
|
||||
content: string; // inside << >>
|
||||
}
|
||||
|
||||
export interface Jump {
|
||||
type: "Jump";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface Detour {
|
||||
type: "Detour";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface Return {
|
||||
type: "Return";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface OptionGroup {
|
||||
type: "OptionGroup";
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
type: "Option";
|
||||
text: string;
|
||||
body: Statement[]; // executed if chosen
|
||||
tags?: string[];
|
||||
css?: string; // Custom CSS style for option
|
||||
markup?: MarkupParseResult;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface IfBlock {
|
||||
type: "If";
|
||||
branches: Array<{
|
||||
condition: string | null; // null for else
|
||||
body: Statement[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OnceBlock {
|
||||
type: "Once";
|
||||
body: Statement[];
|
||||
}
|
||||
|
||||
export interface EnumBlock {
|
||||
type: "Enum";
|
||||
name: string;
|
||||
cases: string[];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
export interface Token {
|
||||
type:
|
||||
| "HEADER_KEY"
|
||||
| "HEADER_VALUE"
|
||||
| "NODE_START" // ---
|
||||
| "NODE_END" // ===
|
||||
| "OPTION" // ->
|
||||
| "COMMAND" // <<...>> (single-line)
|
||||
| "TEXT" // any non-empty content line
|
||||
| "EMPTY"
|
||||
| "INDENT"
|
||||
| "DEDENT"
|
||||
| "EOF";
|
||||
text: string;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
// Minimal indentation-sensitive lexer to support options and their bodies.
|
||||
export function lex(input: string): Token[] {
|
||||
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
||||
const tokens: Token[] = [];
|
||||
const indentStack: number[] = [0];
|
||||
|
||||
let inHeaders = true;
|
||||
|
||||
function push(type: Token["type"], text: string, line: number, column: number) {
|
||||
tokens.push({ type, text, line, column });
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
const lineNum = i + 1;
|
||||
const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
|
||||
const content = raw.slice(indent.length);
|
||||
|
||||
if (content.trim() === "") {
|
||||
push("EMPTY", "", lineNum, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Manage indentation tokens only within node bodies and on non-empty lines
|
||||
if (!inHeaders) {
|
||||
const prev = indentStack[indentStack.length - 1];
|
||||
if (indent.length > prev) {
|
||||
indentStack.push(indent.length);
|
||||
push("INDENT", "", lineNum, 1);
|
||||
} else if (indent.length < prev) {
|
||||
while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
|
||||
indentStack.pop();
|
||||
push("DEDENT", "", lineNum, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content === "---") {
|
||||
inHeaders = false;
|
||||
push("NODE_START", content, lineNum, indent.length + 1);
|
||||
continue;
|
||||
}
|
||||
if (content === "===") {
|
||||
inHeaders = true;
|
||||
// flush indentation to root
|
||||
while (indentStack.length > 1) {
|
||||
indentStack.pop();
|
||||
push("DEDENT", "", lineNum, 1);
|
||||
}
|
||||
push("NODE_END", content, lineNum, indent.length + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Header: key: value (only valid while inHeaders)
|
||||
if (inHeaders) {
|
||||
const m = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
|
||||
if (m) {
|
||||
push("HEADER_KEY", m[1], lineNum, indent.length + 1);
|
||||
push("HEADER_VALUE", m[2], lineNum, indent.length + 1 + m[0].indexOf(m[2]));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (content.startsWith("->")) {
|
||||
push("OPTION", content.slice(2).trim(), lineNum, indent.length + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Commands like <<...>> (single line)
|
||||
const cmd = content.match(/^<<(.+?)>>\s*$/);
|
||||
if (cmd) {
|
||||
push("COMMAND", cmd[1].trim(), lineNum, indent.length + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain text line
|
||||
push("TEXT", content, lineNum, indent.length + 1);
|
||||
}
|
||||
|
||||
// close remaining indentation at EOF
|
||||
while (indentStack.length > 1) {
|
||||
indentStack.pop();
|
||||
tokens.push({ type: "DEDENT", text: "", line: lines.length, column: 1 });
|
||||
}
|
||||
|
||||
tokens.push({ type: "EOF", text: "", line: lines.length + 1, column: 1 });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,540 @@
|
|||
import { lex, Token } from "./lexer";
|
||||
import { parseMarkup, sliceMarkup } from "../markup/parser";
|
||||
import type { MarkupParseResult } from "../markup/types";
|
||||
import type {
|
||||
YarnDocument,
|
||||
YarnNode,
|
||||
Statement,
|
||||
Line,
|
||||
Command,
|
||||
OptionGroup,
|
||||
Option,
|
||||
IfBlock,
|
||||
OnceBlock,
|
||||
Jump,
|
||||
Detour,
|
||||
Return,
|
||||
EnumBlock,
|
||||
} from "../model/ast";
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
export function parseYarn(text: string): YarnDocument {
|
||||
const tokens = lex(text);
|
||||
const p = new Parser(tokens);
|
||||
try{
|
||||
return p.parseDocument();
|
||||
}catch(e){
|
||||
console.log(`parser status: `, p.status());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly tokens: Token[]) {}
|
||||
|
||||
private peek(offset = 0) {
|
||||
return this.tokens[this.i + offset];
|
||||
}
|
||||
private at(type: Token["type"]) {
|
||||
return this.peek()?.type === type;
|
||||
}
|
||||
private take(type: Token["type"], err?: string): Token {
|
||||
const t = this.peek();
|
||||
if (!t || t.type !== type) throw new ParseError(err ?? `Expected ${type}, got ${t?.type}`);
|
||||
this.i++;
|
||||
return t;
|
||||
}
|
||||
private takeIf(type: Token["type"]) {
|
||||
if (this.at(type)) return this.take(type);
|
||||
return null;
|
||||
}
|
||||
|
||||
parseDocument(): YarnDocument {
|
||||
const enums: EnumBlock[] = [];
|
||||
const nodes: YarnNode[] = [];
|
||||
while (!this.at("EOF")) {
|
||||
// Skip empties
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at("EOF")) break;
|
||||
|
||||
// Check if this is an enum definition (top-level)
|
||||
if (this.at("COMMAND")) {
|
||||
const cmd = this.peek().text.trim();
|
||||
if (cmd.startsWith("enum ")) {
|
||||
const enumCmd = this.take("COMMAND").text; // consume the enum command
|
||||
const enumName = enumCmd.slice(5).trim();
|
||||
const enumDef = this.parseEnumBlock(enumName);
|
||||
enums.push(enumDef);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(this.parseNode());
|
||||
}
|
||||
return { type: "Document", enums, nodes };
|
||||
}
|
||||
|
||||
private parseNode(): YarnNode {
|
||||
const headers: Record<string, string> = {};
|
||||
let title: string | null = null;
|
||||
let nodeTags: string[] | undefined;
|
||||
let whenConditions: string[] = [];
|
||||
let nodeCss: string | undefined;
|
||||
|
||||
// headers
|
||||
while (!this.at("NODE_START")) {
|
||||
const keyTok = this.take("HEADER_KEY", "Expected node header before '---'");
|
||||
const valTok = this.take("HEADER_VALUE", "Expected header value");
|
||||
|
||||
// Capture &css{ ... } styles in any header value
|
||||
const rawVal = valTok.text.trim();
|
||||
let finalVal = valTok.text;
|
||||
if (rawVal.startsWith("&css{")) {
|
||||
// Collect until closing '}' possibly spanning multiple lines before '---'
|
||||
let cssContent = rawVal.replace(/^&css\{/, "");
|
||||
let closed = cssContent.includes("}");
|
||||
if (closed) {
|
||||
cssContent = cssContent.split("}")[0];
|
||||
finalVal = rawVal.replace(/^&css\{[^}]*\}/, "").trim();
|
||||
} else {
|
||||
// Consume subsequent TEXT or HEADER_VALUE tokens until we find a '}'
|
||||
while (!this.at("NODE_START") && !this.at("EOF")) {
|
||||
const next = this.peek();
|
||||
if (next.type === "TEXT" || next.type === "HEADER_VALUE") {
|
||||
const t = this.take(next.type).text;
|
||||
if (t.includes("}")) {
|
||||
cssContent += (cssContent ? "\n" : "") + t.split("}")[0];
|
||||
closed = true;
|
||||
finalVal = t.split("}").slice(1).join("}").trim();
|
||||
break;
|
||||
} else {
|
||||
cssContent += (cssContent ? "\n" : "") + t;
|
||||
}
|
||||
} else if (next.type === "EMPTY") {
|
||||
this.i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
nodeCss = (cssContent || "").trim();
|
||||
}
|
||||
|
||||
if (keyTok.text === "title") title = finalVal.trim();
|
||||
if (keyTok.text === "tags") {
|
||||
const raw = finalVal.trim();
|
||||
nodeTags = raw.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
if (keyTok.text === "when") {
|
||||
// Each when: header adds one condition (can have multiple when: headers)
|
||||
const raw = finalVal.trim();
|
||||
whenConditions.push(raw);
|
||||
}
|
||||
headers[keyTok.text] = finalVal;
|
||||
// allow empty lines
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
}
|
||||
if (!title) throw new ParseError("Every node must have a title header");
|
||||
this.take("NODE_START");
|
||||
// allow optional empties after ---
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
|
||||
const body: Statement[] = this.parseStatementsUntil("NODE_END");
|
||||
this.take("NODE_END", "Expected node end '==='");
|
||||
return {
|
||||
type: "Node",
|
||||
title,
|
||||
headers,
|
||||
nodeTags,
|
||||
when: whenConditions.length > 0 ? whenConditions : undefined,
|
||||
css: nodeCss,
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
private parseStatementsUntil(endType: Token["type"]): Statement[] {
|
||||
const out: Statement[] = [];
|
||||
while (!this.at(endType) && !this.at("EOF")) {
|
||||
// skip extra empties
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at(endType) || this.at("EOF")) break;
|
||||
|
||||
// Handle plain indentation seamlessly within blocks
|
||||
if (this.at("INDENT")) {
|
||||
this.take("INDENT");
|
||||
while (!this.at("DEDENT") && !this.at(endType) && !this.at("EOF")) {
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at("DEDENT") || this.at(endType) || this.at("EOF")) break;
|
||||
|
||||
if (this.at("OPTION")) {
|
||||
out.push(this.parseOptionGroup());
|
||||
continue;
|
||||
}
|
||||
out.push(this.parseStatement());
|
||||
}
|
||||
if (this.at("DEDENT")) {
|
||||
this.take("DEDENT");
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.at("OPTION")) {
|
||||
out.push(this.parseOptionGroup());
|
||||
continue;
|
||||
}
|
||||
|
||||
const stmt = this.parseStatement();
|
||||
out.push(stmt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private parseStatement(): Statement {
|
||||
const t = this.peek();
|
||||
if (!t) throw new ParseError("Unexpected EOF");
|
||||
|
||||
if (t.type === "COMMAND") {
|
||||
const cmd = this.take("COMMAND").text;
|
||||
if (cmd.startsWith("jump ")) return { type: "Jump", target: cmd.slice(5).trim() } as Jump;
|
||||
if (cmd.startsWith("detour ")) return { type: "Detour", target: cmd.slice(7).trim() } as Detour;
|
||||
if (cmd.startsWith("return")) return { type: "Return" } as Return;
|
||||
if (cmd.startsWith("if ")) return this.parseIfCommandBlock(cmd);
|
||||
if (cmd === "once") return this.parseOnceBlock();
|
||||
if (cmd.startsWith("enum ")) {
|
||||
const enumName = cmd.slice(5).trim();
|
||||
return this.parseEnumBlock(enumName);
|
||||
}
|
||||
return { type: "Command", content: cmd } as Command;
|
||||
}
|
||||
if (t.type === "TEXT") {
|
||||
const raw = this.take("TEXT").text;
|
||||
const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
|
||||
const markup = parseMarkup(textWithoutTags);
|
||||
const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
|
||||
if (speakerMatch) {
|
||||
const messageText = speakerMatch[2];
|
||||
const messageOffset = markup.text.length - messageText.length;
|
||||
const slicedMarkup = sliceMarkup(markup, messageOffset);
|
||||
const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
|
||||
return {
|
||||
type: "Line",
|
||||
speaker: speakerMatch[1].trim(),
|
||||
text: messageText,
|
||||
tags,
|
||||
markup: normalizedMarkup,
|
||||
} as Line;
|
||||
}
|
||||
// If/Else blocks use inline markup {if ...}
|
||||
const trimmed = markup.text.trim();
|
||||
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
|
||||
return this.parseIfFromText(markup.text);
|
||||
}
|
||||
return {
|
||||
type: "Line",
|
||||
text: markup.text,
|
||||
tags,
|
||||
markup: this.normalizeMarkup(markup),
|
||||
} as Line;
|
||||
}
|
||||
throw new ParseError(`Unexpected token ${t.type}`);
|
||||
}
|
||||
|
||||
private parseOptionGroup(): OptionGroup {
|
||||
const options: Option[] = [];
|
||||
// One or more OPTION lines, with bodies under INDENT
|
||||
while (this.at("OPTION")) {
|
||||
const raw = this.take("OPTION").text;
|
||||
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
|
||||
const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
|
||||
const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
|
||||
const markup = parseMarkup(optionText);
|
||||
let body: Statement[] = [];
|
||||
if (this.at("INDENT")) {
|
||||
this.take("INDENT");
|
||||
body = this.parseStatementsUntil("DEDENT");
|
||||
this.take("DEDENT");
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
}
|
||||
options.push({
|
||||
type: "Option",
|
||||
text: markup.text,
|
||||
body,
|
||||
tags,
|
||||
css,
|
||||
markup: this.normalizeMarkup(markup),
|
||||
condition,
|
||||
});
|
||||
// Consecutive options belong to the same group; break on non-OPTION
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
}
|
||||
return { type: "OptionGroup", options };
|
||||
}
|
||||
|
||||
private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
|
||||
if (!result) return undefined;
|
||||
if (result.segments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const hasFormatting = result.segments.some(
|
||||
(segment) => segment.wrappers.length > 0 || segment.selfClosing
|
||||
);
|
||||
if (!hasFormatting) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments.map((segment) => ({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
wrappers: segment.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
})),
|
||||
selfClosing: segment.selfClosing,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private extractTags(input: string): { cleanText: string; tags?: string[] } {
|
||||
const tags: string[] = [];
|
||||
// Match tags that are space-separated and not part of hex colors or CSS
|
||||
// Tags are like "#tag" preceded by whitespace and not followed by hex digits
|
||||
const re = /\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
|
||||
let text = input;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(input))) {
|
||||
tags.push(m[1]);
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
// Only remove tags that match the pattern (not hex colors in CSS)
|
||||
text = input.replace(/\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, "").trimEnd();
|
||||
return { cleanText: text, tags };
|
||||
}
|
||||
return { cleanText: input };
|
||||
}
|
||||
|
||||
private extractCss(input: string): { text: string; css?: string } {
|
||||
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
|
||||
if (cssMatch) {
|
||||
const css = cssMatch[1].trim();
|
||||
const text = input.replace(cssMatch[0], "").trimEnd();
|
||||
return { text, css };
|
||||
}
|
||||
return { text: input };
|
||||
}
|
||||
|
||||
private extractOptionCondition(input: string): { text: string; condition?: string } {
|
||||
const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
|
||||
if (match) {
|
||||
const text = input.slice(0, match.index).trimEnd();
|
||||
return { text, condition: match[1].trim() };
|
||||
}
|
||||
return { text: input };
|
||||
}
|
||||
|
||||
private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
|
||||
const out: Statement[] = [];
|
||||
while (!this.at("EOF")) {
|
||||
// Check stop condition at root level only
|
||||
if (shouldStop()) break;
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at("EOF") || shouldStop()) break;
|
||||
// Handle indentation - if we see INDENT, parse the indented block
|
||||
if (this.at("INDENT")) {
|
||||
this.take("INDENT");
|
||||
// Parse statements at this indent level until DEDENT (don't check stop condition inside)
|
||||
while (!this.at("DEDENT") && !this.at("EOF")) {
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at("DEDENT") || this.at("EOF")) break;
|
||||
if (this.at("OPTION")) {
|
||||
out.push(this.parseOptionGroup());
|
||||
continue;
|
||||
}
|
||||
out.push(this.parseStatement());
|
||||
}
|
||||
if (this.at("DEDENT")) {
|
||||
this.take("DEDENT");
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (this.at("OPTION")) {
|
||||
out.push(this.parseOptionGroup());
|
||||
continue;
|
||||
}
|
||||
out.push(this.parseStatement());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private parseOnceBlock(): OnceBlock {
|
||||
// Already consumed <<once>>; expect body under INDENT then <<endonce>> as COMMAND
|
||||
let body: Statement[] = [];
|
||||
if (this.at("INDENT")) {
|
||||
this.take("INDENT");
|
||||
body = this.parseStatementsUntil("DEDENT");
|
||||
this.take("DEDENT");
|
||||
} else {
|
||||
// Alternatively, body until explicit <<endonce>> command on single line
|
||||
body = [];
|
||||
}
|
||||
// consume closing command if present on own line
|
||||
if (this.at("COMMAND") && this.peek().text === "endonce") {
|
||||
this.take("COMMAND");
|
||||
}
|
||||
return { type: "Once", body };
|
||||
}
|
||||
|
||||
private parseIfFromText(firstLine: string): IfBlock {
|
||||
const branches: IfBlock["branches"] = [];
|
||||
// expecting state not required in current implementation
|
||||
|
||||
let cursor = firstLine.trim();
|
||||
function parseCond(text: string) {
|
||||
const mIf = text.match(/^\{if\s+(.+?)\}$/);
|
||||
if (mIf) return mIf[1];
|
||||
const mElIf = text.match(/^\{else\s+if\s+(.+?)\}$/);
|
||||
if (mElIf) return mElIf[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const cond = parseCond(cursor);
|
||||
if (cursor === "{else}") {
|
||||
branches.push({ condition: null, body: this.parseIfBlockBody() });
|
||||
// next must be {endif}
|
||||
const endLine = this.take("TEXT", "Expected {endif}").text.trim();
|
||||
if (endLine !== "{endif}") throw new ParseError("Expected {endif}");
|
||||
break;
|
||||
} else if (cond) {
|
||||
branches.push({ condition: cond, body: this.parseIfBlockBody() });
|
||||
// next control line
|
||||
const next = this.take("TEXT", "Expected {else}, {else if}, or {endif}").text.trim();
|
||||
if (next === "{endif}") break;
|
||||
cursor = next;
|
||||
continue;
|
||||
} else if (cursor === "{endif}") {
|
||||
break;
|
||||
} else {
|
||||
throw new ParseError("Invalid if/else control line");
|
||||
}
|
||||
}
|
||||
return { type: "If", branches };
|
||||
}
|
||||
|
||||
private parseEnumBlock(enumName: string): EnumBlock {
|
||||
const cases: string[] = [];
|
||||
|
||||
// Parse cases until <<endenum>>
|
||||
while (!this.at("EOF")) {
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
if (this.at("COMMAND")) {
|
||||
const cmd = this.peek().text.trim();
|
||||
if (cmd === "endenum") {
|
||||
this.take("COMMAND");
|
||||
break;
|
||||
}
|
||||
if (cmd.startsWith("case ")) {
|
||||
this.take("COMMAND");
|
||||
const caseName = cmd.slice(5).trim();
|
||||
cases.push(caseName);
|
||||
} else {
|
||||
// Unknown command, might be inside enum block - skip or break?
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Skip non-command lines
|
||||
if (this.at("TEXT")) this.take("TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "Enum", name: enumName, cases };
|
||||
}
|
||||
|
||||
private parseIfCommandBlock(firstCmd: string): IfBlock {
|
||||
const branches: IfBlock["branches"] = [];
|
||||
const firstCond = firstCmd.slice(3).trim();
|
||||
// Body until next elseif/else/endif command (check at root level, not inside indented blocks)
|
||||
const firstBody = this.parseStatementsUntilStop(() => {
|
||||
// Only stop at root level commands, not inside indented blocks
|
||||
return this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text);
|
||||
});
|
||||
branches.push({ condition: firstCond, body: firstBody });
|
||||
|
||||
while (!this.at("EOF")) {
|
||||
if (!this.at("COMMAND")) break;
|
||||
const t = this.peek();
|
||||
const txt = t.text.trim();
|
||||
if (txt.startsWith("elseif ")) {
|
||||
this.take("COMMAND");
|
||||
const cond = txt.slice(7).trim();
|
||||
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text));
|
||||
branches.push({ condition: cond, body });
|
||||
continue;
|
||||
}
|
||||
if (txt === "else") {
|
||||
this.take("COMMAND");
|
||||
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(endif$)/.test(this.peek().text));
|
||||
branches.push({ condition: null, body });
|
||||
// require endif after else body
|
||||
if (this.at("COMMAND") && this.peek().text.trim() === "endif") {
|
||||
this.take("COMMAND");
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (txt === "endif") {
|
||||
this.take("COMMAND");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { type: "If", branches };
|
||||
}
|
||||
|
||||
private parseIfBlockBody(): Statement[] {
|
||||
// Body is indented lines until next control line or DEDENT boundary; to keep this simple
|
||||
// we consume subsequent lines until encountering a control TEXT or EOF/OPTION/NODE_END.
|
||||
const body: Statement[] = [];
|
||||
while (!this.at("EOF") && !this.at("NODE_END")) {
|
||||
// Stop when next TEXT is a control or when OPTION starts (new group)
|
||||
if (this.at("TEXT")) {
|
||||
const look = this.peek().text.trim();
|
||||
if (look === "{else}" || look === "{endif}" || look.startsWith("{else if ") || look.startsWith("{if ")) break;
|
||||
}
|
||||
if (this.at("OPTION")) break;
|
||||
// Support indented bodies inside if-branches
|
||||
if (this.at("INDENT")) {
|
||||
this.take("INDENT");
|
||||
const nested = this.parseStatementsUntil("DEDENT");
|
||||
this.take("DEDENT");
|
||||
body.push(...nested);
|
||||
// continue scanning after dedent
|
||||
while (this.at("EMPTY")) this.i++;
|
||||
continue;
|
||||
}
|
||||
if (this.at("EMPTY")) {
|
||||
this.i++;
|
||||
continue;
|
||||
}
|
||||
body.push(this.parseStatement());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
public status(){
|
||||
// find the first title before the current token
|
||||
let closestNode = this.tokens.slice(0, this.i).reverse().findIndex(t => t.type === "HEADER_KEY" && t.text === "title");
|
||||
return {
|
||||
i: this.i,
|
||||
tokens: this.tokens,
|
||||
token: this.peek(),
|
||||
closestNode: this.tokens[this.i - closestNode]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Command parser and handler utilities for Yarn Spinner commands.
|
||||
* Commands like <<command_name arg1 arg2>> or <<command_name "arg with spaces">>
|
||||
*/
|
||||
|
||||
import type { ExpressionEvaluator as Evaluator } from "./evaluator";
|
||||
|
||||
export interface ParsedCommand {
|
||||
name: string;
|
||||
args: string[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command string like "command_name arg1 arg2" or "set variable value"
|
||||
* Supports quoted strings with spaces, nested parentheses in expressions.
|
||||
*/
|
||||
export function parseCommand(content: string): ParsedCommand {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Empty command");
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let quoteChar = "";
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
// Handle quote toggling (only when not inside parentheses)
|
||||
if ((char === '"' || char === "'") && !inQuotes && parenDepth === 0) {
|
||||
// Push accumulated non-quoted content as a part
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quoteChar && inQuotes) {
|
||||
// End of quoted string - preserve quotes in the output
|
||||
parts.push(quoteChar + current + quoteChar);
|
||||
quoteChar = "";
|
||||
current = "";
|
||||
inQuotes = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track parenthesis depth to avoid splitting inside expressions
|
||||
if (char === "(" && !inQuotes) {
|
||||
parenDepth++;
|
||||
} else if (char === ")" && !inQuotes) {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
}
|
||||
|
||||
// Split on spaces only when not in quotes and not in parentheses
|
||||
if (char === " " && !inQuotes && parenDepth === 0) {
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
// Push any remaining content
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
throw new Error("No command name found");
|
||||
}
|
||||
|
||||
return {
|
||||
name: parts[0],
|
||||
args: parts.slice(1),
|
||||
raw: content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in command handlers for common Yarn Spinner commands.
|
||||
*/
|
||||
export class CommandHandler {
|
||||
private handlers = new Map<string, (args: string[], evaluator?: Evaluator) => void | Promise<void>>();
|
||||
private variables: Record<string, unknown>;
|
||||
|
||||
constructor(variables: Record<string, unknown> = {}) {
|
||||
this.variables = variables;
|
||||
this.registerBuiltins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a command handler.
|
||||
*/
|
||||
register(name: string, handler: (args: string[], evaluator?: Evaluator) => void | Promise<void>): void {
|
||||
this.handlers.set(name.toLowerCase(), handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a parsed command.
|
||||
*/
|
||||
async execute(parsed: ParsedCommand, evaluator?: Evaluator): Promise<void> {
|
||||
const handler = this.handlers.get(parsed.name.toLowerCase());
|
||||
if (handler) {
|
||||
await handler(parsed.args, evaluator);
|
||||
} else {
|
||||
console.warn(`Unknown command: ${parsed.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private registerBuiltins(): void {
|
||||
// <<set $var to expr>> or <<set $var = expr>> or <<set $var expr>>
|
||||
this.register("set", (args, evaluator) => {
|
||||
if (!evaluator) return;
|
||||
if (args.length < 2) return;
|
||||
const varNameRaw = args[0];
|
||||
let exprParts = args.slice(1);
|
||||
if (exprParts[0] === "to") exprParts = exprParts.slice(1);
|
||||
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
|
||||
const expr = exprParts.join(" ");
|
||||
let value = evaluator.evaluateExpression(expr);
|
||||
|
||||
// If value is a string starting with ".", try to resolve as enum shorthand
|
||||
if (typeof value === "string" && value.startsWith(".")) {
|
||||
const enumType = evaluator.getEnumTypeForVariable(varNameRaw);
|
||||
if (enumType) {
|
||||
value = evaluator.resolveEnumValue(value, enumType);
|
||||
}
|
||||
}
|
||||
|
||||
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
|
||||
// Setting a variable converts it from smart to regular
|
||||
this.variables[key] = value;
|
||||
evaluator.setVariable(key, value);
|
||||
});
|
||||
|
||||
// <<declare $var = expr>>
|
||||
this.register("declare", (args, evaluator) => {
|
||||
if (!evaluator) return;
|
||||
if (args.length < 3) return; // name, '=', expr
|
||||
|
||||
const varNameRaw = args[0];
|
||||
let exprParts = args.slice(1);
|
||||
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
|
||||
const expr = exprParts.join(" ");
|
||||
|
||||
|
||||
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
|
||||
|
||||
// Check if expression is "smart" (contains operators, comparisons, or variable references)
|
||||
// Smart variables: expressions with operators, comparisons, logical ops, or function calls
|
||||
const isSmart = /[+\-*/%<>=!&|]/.test(expr) ||
|
||||
/\$\w+/.test(expr) || // references other variables
|
||||
/[a-zA-Z_]\w*\s*\(/.test(expr); // function calls
|
||||
|
||||
if (isSmart) {
|
||||
// Store as smart variable - will recalculate on each access
|
||||
evaluator.setSmartVariable(key, expr);
|
||||
// Also store initial value in variables for immediate use
|
||||
const initialValue = evaluator.evaluateExpression(expr);
|
||||
this.variables[key] = initialValue;
|
||||
} else {
|
||||
// Regular variable - evaluate once and store
|
||||
let value = evaluator.evaluateExpression(expr);
|
||||
|
||||
// Check if expr is an enum value (EnumName.CaseName or .CaseName)
|
||||
if (typeof value === "string") {
|
||||
// Try to extract enum name from EnumName.CaseName
|
||||
const enumMatch = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
|
||||
if (enumMatch) {
|
||||
const enumName = enumMatch[1];
|
||||
value = evaluator.resolveEnumValue(expr, enumName);
|
||||
} else if (value.startsWith(".")) {
|
||||
// Shorthand - we can't infer enum type from declaration alone
|
||||
// Store as-is, will be resolved on first use if variable has enum type
|
||||
// Value is already set correctly above
|
||||
}
|
||||
}
|
||||
|
||||
this.variables[key] = value;
|
||||
evaluator.setVariable(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// <<stop>> - no-op, just a marker
|
||||
this.register("stop", () => {
|
||||
// Dialogue stop marker
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,558 @@
|
|||
/**
|
||||
* Safe expression evaluator for Yarn Spinner conditions.
|
||||
* Supports variables, functions, comparisons, and logical operators.
|
||||
*/
|
||||
export class ExpressionEvaluator {
|
||||
private smartVariables: Record<string, string> = {}; // variable name -> expression
|
||||
|
||||
constructor(
|
||||
private variables: Record<string, unknown> = {},
|
||||
private functions: Record<string, (...args: unknown[]) => unknown> = {},
|
||||
private enums: Record<string, string[]> = {} // enum name -> cases
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evaluate a condition expression and return a boolean result.
|
||||
* Supports: variables, literals (numbers, strings, booleans), comparisons, logical ops, function calls.
|
||||
*/
|
||||
evaluate(expr: string): boolean {
|
||||
try {
|
||||
const result = this.evaluateExpression(expr);
|
||||
return !!result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an expression that can return any value (not just boolean).
|
||||
*/
|
||||
evaluateExpression(expr: string): unknown {
|
||||
const trimmed = this.preprocess(expr.trim());
|
||||
if (!trimmed) return false;
|
||||
|
||||
// Handle function calls like `functionName(arg1, arg2)`
|
||||
if (this.looksLikeFunctionCall(trimmed)) {
|
||||
return this.evaluateFunctionCall(trimmed);
|
||||
}
|
||||
|
||||
// Handle comparisons
|
||||
if (this.containsComparison(trimmed)) {
|
||||
return this.evaluateComparison(trimmed);
|
||||
}
|
||||
|
||||
// Handle logical operators
|
||||
if (trimmed.includes("&&") || trimmed.includes("||")) {
|
||||
return this.evaluateLogical(trimmed);
|
||||
}
|
||||
|
||||
// Handle negation
|
||||
if (trimmed.startsWith("!")) {
|
||||
return !this.evaluateExpression(trimmed.slice(1).trim());
|
||||
}
|
||||
|
||||
// Handle arithmetic expressions (+, -, *, /, %)
|
||||
if (this.containsArithmetic(trimmed)) {
|
||||
return this.evaluateArithmetic(trimmed);
|
||||
}
|
||||
|
||||
// Simple variable or literal
|
||||
return this.resolveValue(trimmed);
|
||||
}
|
||||
|
||||
private preprocess(expr: string): string {
|
||||
// Normalize operator word aliases to JS-like symbols
|
||||
// Whole word replacements only
|
||||
return expr
|
||||
.replace(/\bnot\b/gi, "!")
|
||||
.replace(/\band\b/gi, "&&")
|
||||
.replace(/\bor\b/gi, "||")
|
||||
.replace(/\bxor\b/gi, "^")
|
||||
.replace(/\beq\b|\bis\b/gi, "==")
|
||||
.replace(/\bneq\b/gi, "!=")
|
||||
.replace(/\bgte\b/gi, ">=")
|
||||
.replace(/\blte\b/gi, "<=")
|
||||
.replace(/\bgt\b/gi, ">")
|
||||
.replace(/\blt\b/gi, "<");
|
||||
}
|
||||
|
||||
private evaluateFunctionCall(expr: string): unknown {
|
||||
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
|
||||
if (!match) throw new Error(`Invalid function call: ${expr}`);
|
||||
|
||||
const [, name, argsStr] = match;
|
||||
const func = this.functions[name];
|
||||
if (!func) throw new Error(`Function not found: ${name}`);
|
||||
|
||||
const args = this.parseArguments(argsStr);
|
||||
const evaluatedArgs = args.map((arg) => this.evaluateExpression(arg.trim()));
|
||||
|
||||
return func(...evaluatedArgs);
|
||||
}
|
||||
|
||||
private parseArguments(argsStr: string): string[] {
|
||||
if (!argsStr.trim()) return [];
|
||||
const args: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of argsStr) {
|
||||
if (char === "(") depth++;
|
||||
else if (char === ")") depth--;
|
||||
else if (char === "," && depth === 0) {
|
||||
args.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current.trim()) args.push(current.trim());
|
||||
return args;
|
||||
}
|
||||
|
||||
private containsComparison(expr: string): boolean {
|
||||
return /[<>=!]/.test(expr);
|
||||
}
|
||||
|
||||
private looksLikeFunctionCall(expr: string): boolean {
|
||||
return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
|
||||
}
|
||||
|
||||
private containsArithmetic(expr: string): boolean {
|
||||
// Remove quoted strings to avoid false positives on "-" or "+" inside literals
|
||||
const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
|
||||
return /[+\-*/%]/.test(unquoted);
|
||||
}
|
||||
|
||||
private evaluateArithmetic(expr: string): number {
|
||||
const input = expr;
|
||||
let index = 0;
|
||||
|
||||
const skipWhitespace = () => {
|
||||
while (index < input.length && /\s/.test(input[index])) {
|
||||
index++;
|
||||
}
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown): number => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
if (value == null || value === "") return 0;
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new Error(`Cannot convert ${String(value)} to number`);
|
||||
}
|
||||
return num;
|
||||
};
|
||||
|
||||
const readToken = (): string => {
|
||||
skipWhitespace();
|
||||
const start = index;
|
||||
let depth = 0;
|
||||
let inQuotes = false;
|
||||
let quoteChar = "";
|
||||
|
||||
while (index < input.length) {
|
||||
const char = input[index];
|
||||
if (inQuotes) {
|
||||
if (char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = "";
|
||||
}
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "(") {
|
||||
depth++;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ")") {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && "+-*/%".includes(char)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (depth === 0 && /\s/.test(char)) {
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return input.slice(start, index).trim();
|
||||
};
|
||||
|
||||
const parsePrimary = (): unknown => {
|
||||
skipWhitespace();
|
||||
if (index >= input.length) {
|
||||
throw new Error("Unexpected end of expression");
|
||||
}
|
||||
|
||||
const char = input[index];
|
||||
if (char === "(") {
|
||||
index++;
|
||||
const value = parseAddSub();
|
||||
skipWhitespace();
|
||||
if (input[index] !== ")") {
|
||||
throw new Error("Unmatched parenthesis in expression");
|
||||
}
|
||||
index++;
|
||||
return value;
|
||||
}
|
||||
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
throw new Error("Invalid expression token");
|
||||
}
|
||||
return this.evaluateExpression(token);
|
||||
};
|
||||
|
||||
const parseUnary = (): number => {
|
||||
skipWhitespace();
|
||||
if (input[index] === "+") {
|
||||
index++;
|
||||
return parseUnary();
|
||||
}
|
||||
if (input[index] === "-") {
|
||||
index++;
|
||||
return -parseUnary();
|
||||
}
|
||||
return toNumber(parsePrimary());
|
||||
};
|
||||
|
||||
const parseMulDiv = (): number => {
|
||||
let value = parseUnary();
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
const char = input[index];
|
||||
if (char === "*" || char === "/" || char === "%") {
|
||||
index++;
|
||||
const right = parseUnary();
|
||||
if (char === "*") {
|
||||
value = value * right;
|
||||
} else if (char === "/") {
|
||||
value = value / right;
|
||||
} else {
|
||||
value = value % right;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseAddSub = (): number => {
|
||||
let value = parseMulDiv();
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
const char = input[index];
|
||||
if (char === "+" || char === "-") {
|
||||
index++;
|
||||
const right = parseMulDiv();
|
||||
if (char === "+") {
|
||||
value = value + right;
|
||||
} else {
|
||||
value = value - right;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const result = parseAddSub();
|
||||
skipWhitespace();
|
||||
if (index < input.length) {
|
||||
throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateComparison(expr: string): boolean {
|
||||
// Match comparison operators (avoid matching !=, <=, >=)
|
||||
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
|
||||
if (!match) throw new Error(`Invalid comparison: ${expr}`);
|
||||
|
||||
const [, left, rawOp, right] = match;
|
||||
const op = rawOp === "=" ? "==" : rawOp;
|
||||
const leftVal = this.evaluateExpression(left.trim());
|
||||
const rightVal = this.evaluateExpression(right.trim());
|
||||
|
||||
switch (op) {
|
||||
case "===":
|
||||
case "==":
|
||||
return this.deepEquals(leftVal, rightVal);
|
||||
case "!==":
|
||||
case "!=":
|
||||
return !this.deepEquals(leftVal, rightVal);
|
||||
case "<":
|
||||
return Number(leftVal) < Number(rightVal);
|
||||
case ">":
|
||||
return Number(leftVal) > Number(rightVal);
|
||||
case "<=":
|
||||
return Number(leftVal) <= Number(rightVal);
|
||||
case ">=":
|
||||
return Number(leftVal) >= Number(rightVal);
|
||||
default:
|
||||
throw new Error(`Unknown operator: ${op}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogical(expr: string): boolean {
|
||||
// Split by && or ||, respecting parentheses
|
||||
const parts: Array<{ expr: string; op: "&&" | "||" | null }> = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
let lastOp: "&&" | "||" | null = null;
|
||||
let i = 0;
|
||||
|
||||
while (i < expr.length) {
|
||||
const char = expr[i];
|
||||
|
||||
if (char === "(") {
|
||||
depth++;
|
||||
current += char;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ")") {
|
||||
depth--;
|
||||
current += char;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for && or || at current position (only at depth 0)
|
||||
if (depth === 0) {
|
||||
if (expr.slice(i, i + 2) === "&&") {
|
||||
if (current.trim()) {
|
||||
parts.push({ expr: current.trim(), op: lastOp });
|
||||
current = "";
|
||||
}
|
||||
lastOp = "&&";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (expr.slice(i, i + 2) === "||") {
|
||||
if (current.trim()) {
|
||||
parts.push({ expr: current.trim(), op: lastOp });
|
||||
current = "";
|
||||
}
|
||||
lastOp = "||";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
parts.push({ expr: current.trim(), op: lastOp });
|
||||
}
|
||||
|
||||
// Simple case: single expression
|
||||
if (parts.length <= 1) {
|
||||
return !!this.evaluateExpression(expr);
|
||||
}
|
||||
|
||||
// Evaluate parts with short-circuit logic
|
||||
let result = this.evaluateExpression(parts[0].expr);
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part.op === "&&") {
|
||||
// Short-circuit: if result is false, no need to evaluate further
|
||||
if (!result) return false;
|
||||
result = result && this.evaluateExpression(part.expr);
|
||||
} else if (part.op === "||") {
|
||||
// Short-circuit: if result is true, no need to evaluate further
|
||||
if (result) return true;
|
||||
result = result || this.evaluateExpression(part.expr);
|
||||
}
|
||||
}
|
||||
|
||||
return !!result;
|
||||
}
|
||||
|
||||
private resolveValue(expr: string): unknown {
|
||||
// Try enum syntax: EnumName.CaseName or .CaseName
|
||||
const enumMatch = expr.match(/^\.?([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
|
||||
if (enumMatch) {
|
||||
const [, enumName, caseName] = enumMatch;
|
||||
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
|
||||
return `${enumName}.${caseName}`; // Store as "EnumName.CaseName" string
|
||||
}
|
||||
}
|
||||
|
||||
// Try shorthand enum: .CaseName (requires context from variables)
|
||||
if (expr.startsWith(".") && expr.length > 1) {
|
||||
// Try to infer enum from variable types - for now, return as-is and let validation handle it
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Try as variable first
|
||||
const key = expr.startsWith("$") ? expr.slice(1) : expr;
|
||||
|
||||
// Check if this is a smart variable (has stored expression)
|
||||
if (Object.prototype.hasOwnProperty.call(this.smartVariables, key)) {
|
||||
// Re-evaluate the expression each time it's accessed
|
||||
return this.evaluateExpression(this.smartVariables[key]);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, key)) {
|
||||
return this.variables[key];
|
||||
}
|
||||
|
||||
// Try as number
|
||||
const num = Number(expr);
|
||||
if (!isNaN(num) && expr.trim() === String(num)) {
|
||||
return num;
|
||||
}
|
||||
|
||||
// Try as boolean
|
||||
if (expr === "true") return true;
|
||||
if (expr === "false") return false;
|
||||
|
||||
// Try as string (quoted)
|
||||
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
|
||||
return expr.slice(1, -1);
|
||||
}
|
||||
|
||||
// Default: treat as variable (may be undefined)
|
||||
return this.variables[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve shorthand enum (.CaseName) when setting a variable with known enum type
|
||||
*/
|
||||
resolveEnumValue(expr: string, enumName?: string): string {
|
||||
if (expr.startsWith(".") && enumName) {
|
||||
const caseName = expr.slice(1);
|
||||
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
|
||||
return `${enumName}.${caseName}`;
|
||||
}
|
||||
throw new Error(`Invalid enum case ${caseName} for enum ${enumName}`);
|
||||
}
|
||||
// Check if it's already EnumName.CaseName format
|
||||
const match = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
|
||||
if (match) {
|
||||
const [, name, caseName] = match;
|
||||
if (this.enums[name] && this.enums[name].includes(caseName)) {
|
||||
return expr;
|
||||
}
|
||||
throw new Error(`Invalid enum case ${caseName} for enum ${name}`);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enum type for a variable (if it was declared with enum type)
|
||||
*/
|
||||
getEnumTypeForVariable(varName: string): string | undefined {
|
||||
// Check if variable value matches EnumName.CaseName pattern
|
||||
const key = varName.startsWith("$") ? varName.slice(1) : varName;
|
||||
const value = this.variables[key];
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(/^([A-Za-z_][A-Za-z0-9_]*)\./);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private deepEquals(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (typeof a === "object") {
|
||||
// Handle arrays
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!this.deepEquals(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Handle plain objects
|
||||
if (!Array.isArray(a) && !Array.isArray(b)) {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||
if (!this.deepEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Mixed types (array vs object)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update variables. Can be used to mutate state during dialogue.
|
||||
*/
|
||||
setVariable(name: string, value: unknown): void {
|
||||
// If setting a smart variable, remove it (converting to regular variable)
|
||||
if (Object.prototype.hasOwnProperty.call(this.smartVariables, name)) {
|
||||
delete this.smartVariables[name];
|
||||
}
|
||||
this.variables[name] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a smart variable (variable with expression that recalculates on access).
|
||||
*/
|
||||
setSmartVariable(name: string, expression: string): void {
|
||||
// Remove from regular variables if it exists
|
||||
if (Object.prototype.hasOwnProperty.call(this.variables, name)) {
|
||||
delete this.variables[name];
|
||||
}
|
||||
this.smartVariables[name] = expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a variable is a smart variable.
|
||||
*/
|
||||
isSmartVariable(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.smartVariables, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable value.
|
||||
*/
|
||||
getVariable(name: string): unknown {
|
||||
return this.variables[name];
|
||||
}
|
||||
|
||||
registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
|
||||
this.functions = {
|
||||
...this.functions,
|
||||
...functions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { MarkupParseResult } from "../markup/types";
|
||||
|
||||
/**
|
||||
* Result emitted when the dialogue produces text/dialogue.
|
||||
*/
|
||||
export type TextResult = {
|
||||
type: "text";
|
||||
text: string;
|
||||
speaker?: string;
|
||||
tags?: string[];
|
||||
markup?: MarkupParseResult;
|
||||
nodeCss?: string; // Node-level CSS from &css{} header
|
||||
scene?: string; // Scene name from node header
|
||||
isDialogueEnd: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result emitted when the dialogue presents options to the user.
|
||||
*/
|
||||
export type OptionsResult = {
|
||||
type: "options";
|
||||
options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
|
||||
nodeCss?: string; // Node-level CSS from &css{} header
|
||||
scene?: string; // Scene name from node header
|
||||
isDialogueEnd: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result emitted when the dialogue executes a command.
|
||||
*/
|
||||
export type CommandResult = {
|
||||
type: "command";
|
||||
command: string;
|
||||
isDialogueEnd: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type of all possible runtime results emitted by the YarnRunner.
|
||||
*/
|
||||
export type RuntimeResult = TextResult | OptionsResult | CommandResult;
|
||||
|
||||
|
|
@ -0,0 +1,662 @@
|
|||
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
|
||||
import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types";
|
||||
import type { RuntimeResult } from "./results";
|
||||
import { ExpressionEvaluator } from "./evaluator";
|
||||
import { CommandHandler, parseCommand } from "./commands";
|
||||
|
||||
export interface RunnerOptions {
|
||||
startAt: string;
|
||||
variables?: Record<string, unknown>;
|
||||
functions?: Record<string, (...args: unknown[]) => unknown>;
|
||||
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
||||
commandHandler?: CommandHandler;
|
||||
onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
|
||||
/**
|
||||
* If true, each runner instance maintains its own once-seen state.
|
||||
* If false (default), all runners share global once-seen state.
|
||||
*/
|
||||
isolated?: boolean;
|
||||
}
|
||||
|
||||
// Global shared state for once-seen tracking (default behavior)
|
||||
const globalOnceSeen = new Set<string>();
|
||||
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
|
||||
|
||||
type CompiledOption = {
|
||||
text: string;
|
||||
tags?: string[];
|
||||
css?: string;
|
||||
markup?: MarkupParseResult;
|
||||
condition?: string;
|
||||
block: IRInstruction[];
|
||||
};
|
||||
|
||||
type CallStackFrame =
|
||||
| { kind: "detour"; title: string; ip: number }
|
||||
| { kind: "block"; title: string; ip: number; block: IRInstruction[]; idx: number };
|
||||
|
||||
export class YarnRunner {
|
||||
private readonly program: IRProgram;
|
||||
private readonly variables: Record<string, unknown>;
|
||||
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
||||
private readonly commandHandler: CommandHandler;
|
||||
private readonly evaluator: ExpressionEvaluator;
|
||||
private readonly onceSeen: Set<string>;
|
||||
private readonly nodeGroupOnceSeen: Set<string>;
|
||||
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
||||
private storyEnded = false;
|
||||
private readonly visitCounts: Record<string, number> = {};
|
||||
private pendingOptions: CompiledOption[] | null = null;
|
||||
|
||||
private nodeTitle: string;
|
||||
private ip = 0; // instruction pointer within node
|
||||
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
|
||||
private callStack: CallStackFrame[] = [];
|
||||
|
||||
currentResult: RuntimeResult | null = null;
|
||||
history: RuntimeResult[] = [];
|
||||
|
||||
constructor(program: IRProgram, opts: RunnerOptions) {
|
||||
this.program = program;
|
||||
this.variables = {};
|
||||
if (opts.variables) {
|
||||
for (const [key, value] of Object.entries(opts.variables)) {
|
||||
const normalizedKey = key.startsWith("$") ? key.slice(1) : key;
|
||||
this.variables[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
// Use isolated state if requested, otherwise share global state
|
||||
if (opts.isolated) {
|
||||
this.onceSeen = new Set<string>();
|
||||
this.nodeGroupOnceSeen = new Set<string>();
|
||||
} else {
|
||||
this.onceSeen = globalOnceSeen;
|
||||
this.nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
||||
}
|
||||
let functions = {
|
||||
// Default conversion helpers
|
||||
string: (v: unknown) => String(v ?? ""),
|
||||
number: (v: unknown) => Number(v),
|
||||
bool: (v: unknown) => Boolean(v),
|
||||
visited: (nodeName: unknown) => {
|
||||
const name = String(nodeName ?? "");
|
||||
return (this.visitCounts[name] ?? 0) > 0;
|
||||
},
|
||||
visited_count: (nodeName: unknown) => {
|
||||
const name = String(nodeName ?? "");
|
||||
return this.visitCounts[name] ?? 0;
|
||||
},
|
||||
format_invariant: (n: unknown) => {
|
||||
const num = Number(n);
|
||||
if (!isFinite(num)) return "0";
|
||||
return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
|
||||
},
|
||||
random: () => Math.random(),
|
||||
random_range: (a: unknown, b: unknown) => {
|
||||
const x = Number(a), y = Number(b);
|
||||
const min = Math.min(x, y);
|
||||
const max = Math.max(x, y);
|
||||
return min + Math.random() * (max - min);
|
||||
},
|
||||
dice: (sides: unknown) => {
|
||||
const s = Math.max(1, Math.floor(Number(sides)) || 1);
|
||||
return Math.floor(Math.random() * s) + 1;
|
||||
},
|
||||
min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
|
||||
max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
|
||||
round: (n: unknown) => Math.round(Number(n)),
|
||||
round_places: (n: unknown, places: unknown) => {
|
||||
const p = Math.max(0, Math.floor(Number(places)) || 0);
|
||||
const factor = Math.pow(10, p);
|
||||
return Math.round(Number(n) * factor) / factor;
|
||||
},
|
||||
floor: (n: unknown) => Math.floor(Number(n)),
|
||||
ceil: (n: unknown) => Math.ceil(Number(n)),
|
||||
inc: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Number.isInteger(v) ? v + 1 : Math.ceil(v);
|
||||
},
|
||||
dec: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Number.isInteger(v) ? v - 1 : Math.floor(v);
|
||||
},
|
||||
decimal: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Math.abs(v - Math.trunc(v));
|
||||
},
|
||||
int: (n: unknown) => Math.trunc(Number(n)),
|
||||
...(opts.functions ?? {}),
|
||||
} as Record<string, (...args: unknown[]) => unknown>;
|
||||
this.handleCommand = opts.handleCommand;
|
||||
this.onStoryEnd = opts.onStoryEnd;
|
||||
this.evaluator = new ExpressionEvaluator(this.variables, functions, this.program.enums);
|
||||
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
||||
this.nodeTitle = opts.startAt;
|
||||
|
||||
this.step();
|
||||
}
|
||||
|
||||
public registerFunctions(functions: Record<string, (...args: unknown[]) => unknown>){
|
||||
this.evaluator.registerFunctions(functions);
|
||||
}
|
||||
|
||||
public registerCommands(commands: Record<string, (args: unknown[], evaluator?: ExpressionEvaluator) => void | Promise<void>>) {
|
||||
for(const key in commands){
|
||||
this.commandHandler.register(key, (args, evaluator) => {
|
||||
if(!evaluator) return;
|
||||
commands[key].call(this, args.map(arg => evaluator.evaluateExpression(arg)));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a node title to an actual node (handling node groups).
|
||||
*/
|
||||
private resolveNode(title: string): IRNode {
|
||||
const nodeOrGroup = this.program.nodes[title];
|
||||
if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
|
||||
|
||||
// If it's a single node, return it
|
||||
if (!("nodes" in nodeOrGroup)) {
|
||||
this.currentNodeIndex = -1;
|
||||
return nodeOrGroup as IRNode;
|
||||
}
|
||||
|
||||
// It's a node group - select the first matching node based on when conditions
|
||||
const group = nodeOrGroup as IRNodeGroup;
|
||||
for (let i = 0; i < group.nodes.length; i++) {
|
||||
const candidate = group.nodes[i];
|
||||
if (this.evaluateWhenConditions(candidate.when, title, i)) {
|
||||
this.currentNodeIndex = i;
|
||||
// If "once" condition, mark as seen immediately
|
||||
if (candidate.when?.includes("once")) {
|
||||
this.markNodeGroupOnceSeen(title, i);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching node found - throw error or return first? Docs suggest error if no match
|
||||
throw new Error(`No matching node found in group ${title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate when conditions for a node in a group.
|
||||
*/
|
||||
private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
// No when condition - available by default (but should not happen in groups)
|
||||
return true;
|
||||
}
|
||||
|
||||
// All conditions must be true (AND logic)
|
||||
for (const condition of conditions) {
|
||||
const trimmed = condition.trim();
|
||||
|
||||
if (trimmed === "once") {
|
||||
// Check if this node has been visited once
|
||||
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
||||
if (this.nodeGroupOnceSeen.has(onceKey)) {
|
||||
return false; // Already seen once
|
||||
}
|
||||
// Will mark as seen when node is entered
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "always") {
|
||||
// Always available
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, treat as expression (e.g., "$has_sword")
|
||||
if (!this.evaluator.evaluate(trimmed)) {
|
||||
return false; // Condition failed
|
||||
}
|
||||
}
|
||||
|
||||
return true; // All conditions passed
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node group node as seen (for "once" condition).
|
||||
*/
|
||||
private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
|
||||
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
||||
this.nodeGroupOnceSeen.add(onceKey);
|
||||
}
|
||||
|
||||
advance(optionIndex?: number) {
|
||||
// If awaiting option selection, consume chosen option by pushing its block
|
||||
if (this.currentResult?.type === "options") {
|
||||
if (optionIndex == null) throw new Error("Option index required");
|
||||
const options = this.pendingOptions;
|
||||
if (!options) throw new Error("Invalid options state");
|
||||
const chosen = options[optionIndex];
|
||||
if (!chosen) throw new Error("Invalid option index");
|
||||
// Push a block frame that we will resume across advances
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
|
||||
this.pendingOptions = null;
|
||||
if (this.resumeBlock()) return;
|
||||
return;
|
||||
}
|
||||
// If we have a pending block, resume it first
|
||||
if (this.resumeBlock()) return;
|
||||
this.step();
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate variables in text and update markup segments accordingly.
|
||||
*/
|
||||
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
|
||||
const evaluateExpression = (expr: string): string => {
|
||||
try {
|
||||
const value = this.evaluator.evaluateExpression(expr.trim());
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!markup) {
|
||||
const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
|
||||
return { text: interpolated };
|
||||
}
|
||||
|
||||
return this.interpolateWithMarkup(text, markup, evaluateExpression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate text while preserving and updating markup segments.
|
||||
*/
|
||||
private interpolateWithMarkup(
|
||||
text: string,
|
||||
markup: MarkupParseResult,
|
||||
evaluateExpression: (expr: string) => string
|
||||
): { text: string; markup?: MarkupParseResult } {
|
||||
const segments = markup.segments.filter((segment) => !segment.selfClosing);
|
||||
const getWrappersAt = (index: number): MarkupWrapper[] => {
|
||||
for (const segment of segments) {
|
||||
if (segment.start <= index && index < segment.end) {
|
||||
return segment.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (index > 0) {
|
||||
return getWrappersAt(index - 1);
|
||||
}
|
||||
return segments[0].wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
};
|
||||
|
||||
const resultChars: string[] = [];
|
||||
const newSegments: MarkupSegment[] = [];
|
||||
let currentSegment: MarkupSegment | null = null;
|
||||
|
||||
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
|
||||
const index = resultChars.length;
|
||||
resultChars.push(char);
|
||||
const wrappersCopy = wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
if (currentSegment && this.wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
|
||||
currentSegment.end = index + 1;
|
||||
} else {
|
||||
this.flushSegment(currentSegment, newSegments);
|
||||
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
|
||||
}
|
||||
};
|
||||
|
||||
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
|
||||
if (!value) {
|
||||
this.flushSegment(currentSegment, newSegments);
|
||||
currentSegment = null;
|
||||
return;
|
||||
}
|
||||
for (const ch of value) {
|
||||
appendCharWithWrappers(ch, wrappers);
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
if (char === '{') {
|
||||
const close = text.indexOf('}', i + 1);
|
||||
if (close === -1) {
|
||||
appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const expr = text.slice(i + 1, close);
|
||||
const evaluated = evaluateExpression(expr);
|
||||
const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
|
||||
appendStringWithWrappers(evaluated, wrappers);
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
appendCharWithWrappers(char, getWrappersAt(i));
|
||||
i += 1;
|
||||
}
|
||||
|
||||
this.flushSegment(currentSegment, newSegments);
|
||||
const interpolatedText = resultChars.join('');
|
||||
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
|
||||
return { text: interpolatedText, markup: normalizedMarkup };
|
||||
}
|
||||
|
||||
private wrappersEqual(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const wa = a[i];
|
||||
const wb = b[i];
|
||||
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
||||
const keysA = Object.keys(wa.properties);
|
||||
const keysB = Object.keys(wb.properties);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (wa.properties[key] !== wb.properties[key]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private flushSegment(segment: MarkupSegment | null, segments: MarkupSegment[]): void {
|
||||
if (segment) {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
|
||||
if (!result) return undefined;
|
||||
if (result.segments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const hasFormatting = result.segments.some(
|
||||
(segment) => segment.wrappers.length > 0 || segment.selfClosing
|
||||
);
|
||||
if (!hasFormatting) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments.map((segment) => ({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
wrappers: segment.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
})),
|
||||
selfClosing: segment.selfClosing,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private resumeBlock(): boolean {
|
||||
const top = this.callStack[this.callStack.length - 1];
|
||||
if (!top || top.kind !== "block") return false;
|
||||
// Execute from stored idx until we emit one result or finish block
|
||||
while (true) {
|
||||
const ins = top.block[top.idx++];
|
||||
if (!ins) {
|
||||
// finished block; pop and continue main step
|
||||
this.callStack.pop();
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
switch (ins.op) {
|
||||
case "line": {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
||||
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
|
||||
return true;
|
||||
}
|
||||
case "command": {
|
||||
const parsed = parseCommand(ins.content);
|
||||
// Execute command handler (errors are caught internally)
|
||||
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
|
||||
console.warn(`Command execution error: ${err}`);
|
||||
});
|
||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
||||
return true;
|
||||
}
|
||||
case "options": {
|
||||
const available = this.filterOptions(ins.options);
|
||||
if (available.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.pendingOptions = available;
|
||||
this.emit({
|
||||
type: "options",
|
||||
options: available.map((o) => {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
|
||||
return { text: interpolatedText, tags: o.tags, markup: interpolatedMarkup };
|
||||
}),
|
||||
isDialogueEnd: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "if": {
|
||||
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
||||
if (branch) {
|
||||
// Push nested block at current top position (resume after)
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
||||
return this.resumeBlock();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "once": {
|
||||
if (!this.onceSeen.has(ins.id)) {
|
||||
this.onceSeen.add(ins.id);
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
||||
return this.resumeBlock();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "jump": {
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
case "detour": {
|
||||
this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private step() {
|
||||
while (true) {
|
||||
const resolved = this.resolveNode(this.nodeTitle);
|
||||
const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
|
||||
const ins = currentNode.instructions[this.ip];
|
||||
if (!ins) {
|
||||
// Node ended
|
||||
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
||||
this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
|
||||
return;
|
||||
}
|
||||
this.ip++;
|
||||
switch (ins.op) {
|
||||
case "line": {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
||||
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
||||
return;
|
||||
}
|
||||
case "command": {
|
||||
const parsed = parseCommand(ins.content);
|
||||
// Execute command handler (errors are caught internally)
|
||||
this.commandHandler.execute(parsed, this.evaluator).catch((err) => {
|
||||
console.warn(`Command execution error: ${err}`);
|
||||
});
|
||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
|
||||
return;
|
||||
}
|
||||
case "jump": {
|
||||
// Exiting current node due to jump
|
||||
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||
// resolveNode will handle node groups
|
||||
continue;
|
||||
}
|
||||
case "detour": {
|
||||
// Save return position, jump to target node, return when it ends
|
||||
this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||
// resolveNode will handle node groups
|
||||
continue;
|
||||
}
|
||||
case "return": {
|
||||
const top = this.callStack.pop();
|
||||
if (!top) {
|
||||
console.warn("Return called with empty call stack");
|
||||
continue;
|
||||
}
|
||||
this.nodeTitle = top.title;
|
||||
this.ip = top.ip;
|
||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||
continue;
|
||||
}
|
||||
case "options": {
|
||||
const available = this.filterOptions(ins.options);
|
||||
if (available.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.pendingOptions = available;
|
||||
this.emit({
|
||||
type: "options",
|
||||
options: available.map((o) => {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
|
||||
return { text: interpolatedText, tags: o.tags, css: o.css, markup: interpolatedMarkup };
|
||||
}),
|
||||
nodeCss: resolved.css,
|
||||
scene: resolved.scene,
|
||||
isDialogueEnd: this.lookaheadIsEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "if": {
|
||||
const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
||||
if (branch) {
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
||||
if (this.resumeBlock()) return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "once": {
|
||||
if (!this.onceSeen.has(ins.id)) {
|
||||
this.onceSeen.add(ins.id);
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
||||
if (this.resumeBlock()) return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private filterOptions(options: CompiledOption[]): CompiledOption[] {
|
||||
const available: CompiledOption[] = [];
|
||||
for (const option of options) {
|
||||
if (!option.condition) {
|
||||
available.push(option);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (this.evaluator.evaluate(option.condition)) {
|
||||
available.push(option);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Option condition evaluation error: ${err}`);
|
||||
// Treat errors as false conditions
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
private lookaheadIsEnd(): boolean {
|
||||
// Check if current node has more emit-worthy instructions ahead
|
||||
const node = this.resolveNode(this.nodeTitle);
|
||||
for (let k = this.ip; k < node.instructions.length; k++) {
|
||||
const op = node.instructions[k]?.op;
|
||||
if (!op) break;
|
||||
// These instructions produce output or control flow changes
|
||||
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
|
||||
if (op === "jump" || op === "detour") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private emit(res: RuntimeResult) {
|
||||
this.currentResult = res;
|
||||
this.history.push(res);
|
||||
if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
|
||||
this.storyEnded = true;
|
||||
if (this.onStoryEnd) {
|
||||
// Create a readonly copy of the variables
|
||||
const variablesCopy = Object.freeze({ ...this.variables });
|
||||
this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
|
||||
}
|
||||
}
|
||||
// If we ended a detour node, return to caller after emitting last result
|
||||
// Position is restored here, but we wait for next advance() to continue
|
||||
if (res.isDialogueEnd && this.callStack.length > 0) {
|
||||
const frame = this.callStack.pop()!;
|
||||
this.nodeTitle = frame.title;
|
||||
this.ip = frame.ip;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current variable store (read-only view).
|
||||
*/
|
||||
getVariables(): Readonly<Record<string, unknown>> {
|
||||
return { ...this.variables };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable value.
|
||||
*/
|
||||
getVariable(name: string): unknown {
|
||||
if(this.evaluator.isSmartVariable(name))
|
||||
return this.evaluator.evaluateExpression(`$${name}`);
|
||||
return this.variables[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set variable value.
|
||||
*/
|
||||
setVariable(name: string, value: unknown): void {
|
||||
this.variables[name] = value;
|
||||
this.evaluator.setVariable(name, value);
|
||||
}
|
||||
|
||||
setSmartVariable(name: string, expression: string): void {
|
||||
this.evaluator.setSmartVariable(name, expression);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue