feat: yarn runner
This commit is contained in:
parent
f64775e735
commit
2a9281c9dc
|
|
@ -1 +1,3 @@
|
|||
export {parseYarn} from './parse/parser';
|
||||
export {parseYarn} from './parse/parser';
|
||||
export {compile} from './compile/compiler';
|
||||
export {YarnRunner} from "./runtime/runner";
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* 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"
|
||||
*/
|
||||
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 = "";
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
// If we have accumulated non-quoted content (e.g. a function name and "(")
|
||||
// push it as its own part before entering quoted mode. This prevents the
|
||||
// surrounding text from being merged into the quoted content when we
|
||||
// later push the quoted value.
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quoteChar && inQuotes) {
|
||||
inQuotes = false;
|
||||
// Preserve the surrounding quotes in the parsed part so callers that
|
||||
// reassemble the expression (e.g. declare handlers) keep string literals
|
||||
// intact instead of losing quote characters.
|
||||
parts.push(quoteChar + current + quoteChar);
|
||||
quoteChar = "";
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === " " && !inQuotes) {
|
||||
if (current.trim()) {
|
||||
parts.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
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,502 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
for (const char of expr) {
|
||||
if (char === "(") depth++;
|
||||
else if (char === ")") depth--;
|
||||
else if (depth === 0 && expr.includes(char === "&" ? "&&" : char === "|" ? "||" : "")) {
|
||||
// Check for && or ||
|
||||
const remaining = expr.slice(expr.indexOf(char));
|
||||
if (remaining.startsWith("&&")) {
|
||||
if (current.trim()) {
|
||||
parts.push({ expr: current.trim(), op: lastOp });
|
||||
current = "";
|
||||
}
|
||||
lastOp = "&&";
|
||||
// skip &&
|
||||
continue;
|
||||
} else if (remaining.startsWith("||")) {
|
||||
if (current.trim()) {
|
||||
parts.push({ expr: current.trim(), op: lastOp });
|
||||
current = "";
|
||||
}
|
||||
lastOp = "||";
|
||||
// skip ||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current.trim()) parts.push({ expr: current.trim(), op: lastOp });
|
||||
|
||||
// Simple case: single expression
|
||||
if (parts.length === 0) return !!this.evaluateExpression(expr);
|
||||
|
||||
// Evaluate parts (supports &&, ||, ^ as xor)
|
||||
let result = this.evaluateExpression(parts[0].expr);
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const val = this.evaluateExpression(part.expr);
|
||||
if (part.op === "&&") {
|
||||
result = result && val;
|
||||
} else if (part.op === "||") {
|
||||
result = result || val;
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { MarkupParseResult } from "../markup/types";
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type CommandResult = {
|
||||
type: "command";
|
||||
command: string;
|
||||
isDialogueEnd: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeResult = TextResult | OptionsResult | CommandResult;
|
||||
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
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;
|
||||
}
|
||||
|
||||
const globalOnceSeen = new Set<string>();
|
||||
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
|
||||
|
||||
type CompiledOption = {
|
||||
text: string;
|
||||
tags?: string[];
|
||||
css?: string;
|
||||
markup?: MarkupParseResult;
|
||||
condition?: string;
|
||||
block: IRInstruction[];
|
||||
};
|
||||
|
||||
export class YarnRunner {
|
||||
private readonly program: IRProgram;
|
||||
private readonly variables: Record<string, unknown>;
|
||||
private readonly functions: Record<string, (...args: unknown[]) => unknown>;
|
||||
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
||||
private readonly commandHandler: CommandHandler;
|
||||
private readonly evaluator: ExpressionEvaluator;
|
||||
private readonly onceSeen = globalOnceSeen;
|
||||
private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
|
||||
private storyEnded = false;
|
||||
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
||||
private readonly visitCounts: Record<string, number> = {};
|
||||
private pendingOptions: CompiledOption[] | null = null;
|
||||
|
||||
private nodeTitle: string;
|
||||
private ip = 0; // instruction pointer within node
|
||||
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
|
||||
private callStack: Array<
|
||||
| ({ title: string; ip: number } & { kind: "detour" })
|
||||
| ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
|
||||
> = [];
|
||||
|
||||
currentResult: RuntimeResult | null = null;
|
||||
history: RuntimeResult[] = [];
|
||||
|
||||
constructor(program: IRProgram, opts: RunnerOptions) {
|
||||
this.program = program;
|
||||
this.variables = {};
|
||||
if (opts.variables) {
|
||||
for (const [key, value] of Object.entries(opts.variables)) {
|
||||
const normalizedKey = key.startsWith("$") ? key.slice(1) : key;
|
||||
this.variables[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
this.functions = {
|
||||
// Default conversion helpers
|
||||
string: (v: unknown) => String(v ?? ""),
|
||||
number: (v: unknown) => Number(v),
|
||||
bool: (v: unknown) => Boolean(v),
|
||||
visited: (nodeName: unknown) => {
|
||||
const name = String(nodeName ?? "");
|
||||
return (this.visitCounts[name] ?? 0) > 0;
|
||||
},
|
||||
visited_count: (nodeName: unknown) => {
|
||||
const name = String(nodeName ?? "");
|
||||
return this.visitCounts[name] ?? 0;
|
||||
},
|
||||
format_invariant: (n: unknown) => {
|
||||
const num = Number(n);
|
||||
if (!isFinite(num)) return "0";
|
||||
return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
|
||||
},
|
||||
random: () => Math.random(),
|
||||
random_range: (a: unknown, b: unknown) => {
|
||||
const x = Number(a), y = Number(b);
|
||||
const min = Math.min(x, y);
|
||||
const max = Math.max(x, y);
|
||||
return min + Math.random() * (max - min);
|
||||
},
|
||||
dice: (sides: unknown) => {
|
||||
const s = Math.max(1, Math.floor(Number(sides)) || 1);
|
||||
return Math.floor(Math.random() * s) + 1;
|
||||
},
|
||||
min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
|
||||
max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
|
||||
round: (n: unknown) => Math.round(Number(n)),
|
||||
round_places: (n: unknown, places: unknown) => {
|
||||
const p = Math.max(0, Math.floor(Number(places)) || 0);
|
||||
const factor = Math.pow(10, p);
|
||||
return Math.round(Number(n) * factor) / factor;
|
||||
},
|
||||
floor: (n: unknown) => Math.floor(Number(n)),
|
||||
ceil: (n: unknown) => Math.ceil(Number(n)),
|
||||
inc: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Number.isInteger(v) ? v + 1 : Math.ceil(v);
|
||||
},
|
||||
dec: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Number.isInteger(v) ? v - 1 : Math.floor(v);
|
||||
},
|
||||
decimal: (n: unknown) => {
|
||||
const v = Number(n);
|
||||
return Math.abs(v - Math.trunc(v));
|
||||
},
|
||||
int: (n: unknown) => Math.trunc(Number(n)),
|
||||
...(opts.functions ?? {}),
|
||||
} as Record<string, (...args: unknown[]) => unknown>;
|
||||
this.handleCommand = opts.handleCommand;
|
||||
this.onStoryEnd = opts.onStoryEnd;
|
||||
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
|
||||
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
||||
this.nodeTitle = opts.startAt;
|
||||
|
||||
this.step();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current node title (may resolve to a node group).
|
||||
*/
|
||||
getCurrentNodeTitle(): string {
|
||||
return this.nodeTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node title to an actual node (handling node groups).
|
||||
*/
|
||||
private resolveNode(title: string): IRNode {
|
||||
const nodeOrGroup = this.program.nodes[title];
|
||||
if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
|
||||
|
||||
// If it's a single node, return it
|
||||
if (!("nodes" in nodeOrGroup)) {
|
||||
this.currentNodeIndex = -1;
|
||||
return nodeOrGroup as IRNode;
|
||||
}
|
||||
|
||||
// It's a node group - select the first matching node based on when conditions
|
||||
const group = nodeOrGroup as IRNodeGroup;
|
||||
for (let i = 0; i < group.nodes.length; i++) {
|
||||
const candidate = group.nodes[i];
|
||||
if (this.evaluateWhenConditions(candidate.when, title, i)) {
|
||||
this.currentNodeIndex = i;
|
||||
// If "once" condition, mark as seen immediately
|
||||
if (candidate.when?.includes("once")) {
|
||||
this.markNodeGroupOnceSeen(title, i);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching node found - throw error or return first? Docs suggest error if no match
|
||||
throw new Error(`No matching node found in group ${title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate when conditions for a node in a group.
|
||||
*/
|
||||
private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
// No when condition - available by default (but should not happen in groups)
|
||||
return true;
|
||||
}
|
||||
|
||||
// All conditions must be true (AND logic)
|
||||
for (const condition of conditions) {
|
||||
const trimmed = condition.trim();
|
||||
|
||||
if (trimmed === "once") {
|
||||
// Check if this node has been visited once
|
||||
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
||||
if (this.nodeGroupOnceSeen.has(onceKey)) {
|
||||
return false; // Already seen once
|
||||
}
|
||||
// Will mark as seen when node is entered
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "always") {
|
||||
// Always available
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, treat as expression (e.g., "$has_sword")
|
||||
if (!this.evaluator.evaluate(trimmed)) {
|
||||
return false; // Condition failed
|
||||
}
|
||||
}
|
||||
|
||||
return true; // All conditions passed
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node group node as seen (for "once" condition).
|
||||
*/
|
||||
private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
|
||||
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
||||
this.nodeGroupOnceSeen.add(onceKey);
|
||||
}
|
||||
|
||||
advance(optionIndex?: number) {
|
||||
// If awaiting option selection, consume chosen option by pushing its block
|
||||
if (this.currentResult?.type === "options") {
|
||||
if (optionIndex == null) throw new Error("Option index required");
|
||||
const options = this.pendingOptions;
|
||||
if (!options) throw new Error("Invalid options state");
|
||||
const chosen = options[optionIndex];
|
||||
if (!chosen) throw new Error("Invalid option index");
|
||||
// Push a block frame that we will resume across advances
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
|
||||
this.pendingOptions = null;
|
||||
if (this.resumeBlock()) return;
|
||||
return;
|
||||
}
|
||||
// If we have a pending block, resume it first
|
||||
if (this.resumeBlock()) return;
|
||||
this.step();
|
||||
}
|
||||
|
||||
private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
|
||||
const evaluateExpression = (expr: string): string => {
|
||||
try {
|
||||
const value = this.evaluator.evaluateExpression(expr.trim());
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!markup) {
|
||||
const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
|
||||
return { text: interpolated };
|
||||
}
|
||||
|
||||
const segments = markup.segments.filter((segment) => !segment.selfClosing);
|
||||
const getWrappersAt = (index: number): MarkupWrapper[] => {
|
||||
for (const segment of segments) {
|
||||
if (segment.start <= index && index < segment.end) {
|
||||
return segment.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (index > 0) {
|
||||
return getWrappersAt(index - 1);
|
||||
}
|
||||
return segments[0].wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
};
|
||||
|
||||
const resultChars: string[] = [];
|
||||
const newSegments: MarkupSegment[] = [];
|
||||
let currentSegment: MarkupSegment | null = null;
|
||||
|
||||
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const wa = a[i];
|
||||
const wb = b[i];
|
||||
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
||||
const keysA = Object.keys(wa.properties);
|
||||
const keysB = Object.keys(wb.properties);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (wa.properties[key] !== wb.properties[key]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const flushSegment = () => {
|
||||
if (currentSegment) {
|
||||
newSegments.push(currentSegment);
|
||||
currentSegment = null;
|
||||
}
|
||||
};
|
||||
|
||||
const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
|
||||
const index = resultChars.length;
|
||||
resultChars.push(char);
|
||||
const wrappersCopy = wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
}));
|
||||
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
|
||||
currentSegment.end = index + 1;
|
||||
} else {
|
||||
flushSegment();
|
||||
currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
|
||||
}
|
||||
};
|
||||
|
||||
const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
|
||||
if (!value) {
|
||||
flushSegment();
|
||||
return;
|
||||
}
|
||||
for (const ch of value) {
|
||||
appendCharWithWrappers(ch, wrappers);
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
if (char === '{') {
|
||||
const close = text.indexOf('}', i + 1);
|
||||
if (close === -1) {
|
||||
appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const expr = text.slice(i + 1, close);
|
||||
const evaluated = evaluateExpression(expr);
|
||||
const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
|
||||
appendStringWithWrappers(evaluated, wrappers);
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
appendCharWithWrappers(char, getWrappersAt(i));
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flushSegment();
|
||||
const interpolatedText = resultChars.join('');
|
||||
const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
|
||||
return { text: interpolatedText, markup: normalizedMarkup };
|
||||
}
|
||||
|
||||
private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
|
||||
if (!result) return undefined;
|
||||
if (result.segments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const hasFormatting = result.segments.some(
|
||||
(segment) => segment.wrappers.length > 0 || segment.selfClosing
|
||||
);
|
||||
if (!hasFormatting) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments.map((segment) => ({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
wrappers: segment.wrappers.map((wrapper) => ({
|
||||
name: wrapper.name,
|
||||
type: wrapper.type,
|
||||
properties: { ...wrapper.properties },
|
||||
})),
|
||||
selfClosing: segment.selfClosing,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private resumeBlock(): boolean {
|
||||
const top = this.callStack[this.callStack.length - 1];
|
||||
if (!top || top.kind !== "block") return false;
|
||||
// Execute from stored idx until we emit one result or finish block
|
||||
while (true) {
|
||||
const ins = top.block[top.idx++];
|
||||
if (!ins) {
|
||||
// finished block; pop and continue main step
|
||||
this.callStack.pop();
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
switch (ins.op) {
|
||||
case "line": {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
||||
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
|
||||
return true;
|
||||
}
|
||||
case "command": {
|
||||
try {
|
||||
const parsed = parseCommand(ins.content);
|
||||
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||
} catch {
|
||||
if (this.handleCommand) this.handleCommand(ins.content);
|
||||
}
|
||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
||||
return true;
|
||||
}
|
||||
case "options": {
|
||||
const available = this.filterOptions(ins.options);
|
||||
if (available.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.pendingOptions = available;
|
||||
this.emit({
|
||||
type: "options",
|
||||
options: available.map((o) => {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
|
||||
return { text: interpolatedText, tags: o.tags, markup: interpolatedMarkup };
|
||||
}),
|
||||
isDialogueEnd: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "if": {
|
||||
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
||||
if (branch) {
|
||||
// Push nested block at current top position (resume after)
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
||||
return this.resumeBlock();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "once": {
|
||||
if (!this.onceSeen.has(ins.id)) {
|
||||
this.onceSeen.add(ins.id);
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
||||
return this.resumeBlock();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "jump": {
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
case "detour": {
|
||||
this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private step() {
|
||||
while (true) {
|
||||
const resolved = this.resolveNode(this.nodeTitle);
|
||||
const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
|
||||
const ins = currentNode.instructions[this.ip];
|
||||
if (!ins) {
|
||||
// Node ended
|
||||
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
||||
this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
|
||||
return;
|
||||
}
|
||||
this.ip++;
|
||||
switch (ins.op) {
|
||||
case "line": {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
||||
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
||||
return;
|
||||
}
|
||||
case "command": {
|
||||
try {
|
||||
const parsed = parseCommand(ins.content);
|
||||
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||
} catch {
|
||||
if (this.handleCommand) this.handleCommand(ins.content);
|
||||
}
|
||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
|
||||
return;
|
||||
}
|
||||
case "jump": {
|
||||
// Exiting current node due to jump
|
||||
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||
// resolveNode will handle node groups
|
||||
continue;
|
||||
}
|
||||
case "detour": {
|
||||
// Save return position, jump to target node, return when it ends
|
||||
this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.currentNodeIndex = -1; // Reset node index for new resolution
|
||||
// resolveNode will handle node groups
|
||||
continue;
|
||||
}
|
||||
case "options": {
|
||||
const available = this.filterOptions(ins.options);
|
||||
if (available.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.pendingOptions = available;
|
||||
this.emit({
|
||||
type: "options",
|
||||
options: available.map((o) => {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
|
||||
return { text: interpolatedText, tags: o.tags, css: o.css, markup: interpolatedMarkup };
|
||||
}),
|
||||
nodeCss: resolved.css,
|
||||
scene: resolved.scene,
|
||||
isDialogueEnd: this.lookaheadIsEnd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "if": {
|
||||
const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
||||
if (branch) {
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
||||
if (this.resumeBlock()) return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "once": {
|
||||
if (!this.onceSeen.has(ins.id)) {
|
||||
this.onceSeen.add(ins.id);
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
||||
if (this.resumeBlock()) return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private executeBlock(block: { title: string; instructions: IRInstruction[] }) {
|
||||
// Execute instructions of block, then resume
|
||||
const saved = { title: this.nodeTitle, ip: this.ip } as const;
|
||||
this.nodeTitle = block.title;
|
||||
const tempIpStart = 0;
|
||||
const tempNode = { title: block.title, instructions: block.instructions } as const;
|
||||
// Use a temporary node context
|
||||
const restore = () => {
|
||||
this.nodeTitle = saved.title;
|
||||
this.ip = saved.ip;
|
||||
};
|
||||
|
||||
// Step through block, emitting first result
|
||||
let idx = tempIpStart;
|
||||
while (true) {
|
||||
const ins = tempNode.instructions[idx++];
|
||||
if (!ins) break;
|
||||
switch (ins.op) {
|
||||
case "line": {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
|
||||
this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, markup: interpolatedMarkup, isDialogueEnd: false });
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
case "command":
|
||||
try {
|
||||
const parsed = parseCommand(ins.content);
|
||||
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
||||
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
||||
} catch {
|
||||
if (this.handleCommand) this.handleCommand(ins.content);
|
||||
}
|
||||
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
||||
restore();
|
||||
return;
|
||||
case "options": {
|
||||
const available = this.filterOptions(ins.options);
|
||||
if (available.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.pendingOptions = available;
|
||||
this.emit({
|
||||
type: "options",
|
||||
options: available.map((o) => {
|
||||
const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(o.text, o.markup);
|
||||
return { text: interpolatedText, markup: interpolatedMarkup };
|
||||
}),
|
||||
isDialogueEnd: false,
|
||||
});
|
||||
// Maintain context that options belong to main node at ip-1
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
case "if": {
|
||||
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
||||
if (branch) {
|
||||
// enqueue nested block and resume from main context
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
||||
restore();
|
||||
if (this.resumeBlock()) return;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "once": {
|
||||
if (!this.onceSeen.has(ins.id)) {
|
||||
this.onceSeen.add(ins.id);
|
||||
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
||||
restore();
|
||||
if (this.resumeBlock()) return;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "jump": {
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return;
|
||||
}
|
||||
case "detour": {
|
||||
this.callStack.push({ kind: "detour", title: saved.title, ip: saved.ip });
|
||||
this.nodeTitle = ins.target;
|
||||
this.ip = 0;
|
||||
this.step();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Block produced no output; resume
|
||||
restore();
|
||||
this.step();
|
||||
}
|
||||
|
||||
private filterOptions(options: CompiledOption[]): CompiledOption[] {
|
||||
const available: CompiledOption[] = [];
|
||||
for (const option of options) {
|
||||
if (!option.condition) {
|
||||
available.push(option);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (this.evaluator.evaluate(option.condition)) {
|
||||
available.push(option);
|
||||
}
|
||||
} catch {
|
||||
// Treat errors as false conditions
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
private lookaheadIsEnd(): boolean {
|
||||
// Check if current node has more emit-worthy instructions
|
||||
const node = this.resolveNode(this.nodeTitle);
|
||||
for (let k = this.ip; k < node.instructions.length; k++) {
|
||||
const op = node.instructions[k]?.op;
|
||||
if (!op) break;
|
||||
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
|
||||
if (op === "jump" || op === "detour") return false;
|
||||
}
|
||||
// Node is ending - mark as end (will trigger detour return if callStack exists)
|
||||
return true;
|
||||
}
|
||||
|
||||
private emit(res: RuntimeResult) {
|
||||
this.currentResult = res;
|
||||
this.history.push(res);
|
||||
if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
|
||||
this.storyEnded = true;
|
||||
if (this.onStoryEnd) {
|
||||
// Create a readonly copy of the variables
|
||||
const variablesCopy = Object.freeze({ ...this.variables });
|
||||
this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
|
||||
}
|
||||
}
|
||||
// If we ended a detour node, return to caller after emitting last result
|
||||
// Position is restored here, but we wait for next advance() to continue
|
||||
if (res.isDialogueEnd && this.callStack.length > 0) {
|
||||
const frame = this.callStack.pop()!;
|
||||
this.nodeTitle = frame.title;
|
||||
this.ip = frame.ip;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current variable store (read-only view).
|
||||
*/
|
||||
getVariables(): Readonly<Record<string, unknown>> {
|
||||
return { ...this.variables };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable value.
|
||||
*/
|
||||
getVariable(name: string): unknown {
|
||||
return this.variables[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set variable value.
|
||||
*/
|
||||
setVariable(name: string, value: unknown): void {
|
||||
this.variables[name] = value;
|
||||
this.evaluator.setVariable(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue