From f1aa536c4d56128b4258a95353bfbea81b5eea31 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 3 Mar 2026 18:29:59 +0800 Subject: [PATCH] refactor: ai's attempt --- src/yarn-spinner/runtime/commands.ts | 27 ++++-- src/yarn-spinner/runtime/evaluator.ts | 83 ++++++++++++---- src/yarn-spinner/runtime/results.ts | 13 +++ src/yarn-spinner/runtime/runner.ts | 133 ++++++++++++++++---------- 4 files changed, 178 insertions(+), 78 deletions(-) diff --git a/src/yarn-spinner/runtime/commands.ts b/src/yarn-spinner/runtime/commands.ts index 83e1932..0ab2d53 100644 --- a/src/yarn-spinner/runtime/commands.ts +++ b/src/yarn-spinner/runtime/commands.ts @@ -13,6 +13,7 @@ export interface ParsedCommand { /** * 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(); @@ -24,15 +25,14 @@ export function parseCommand(content: string): ParsedCommand { let current = ""; let inQuotes = false; let quoteChar = ""; + let parenDepth = 0; 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. + // 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 = ""; @@ -43,17 +43,23 @@ export function parseCommand(content: string): ParsedCommand { } 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. + // End of quoted string - preserve quotes in the output parts.push(quoteChar + current + quoteChar); quoteChar = ""; current = ""; + inQuotes = false; continue; } - if (char === " " && !inQuotes) { + // 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 = ""; @@ -64,6 +70,7 @@ export function parseCommand(content: string): ParsedCommand { current += char; } + // Push any remaining content if (current.trim()) { parts.push(current.trim()); } diff --git a/src/yarn-spinner/runtime/evaluator.ts b/src/yarn-spinner/runtime/evaluator.ts index 7c65128..e32e80d 100644 --- a/src/yarn-spinner/runtime/evaluator.ts +++ b/src/yarn-spinner/runtime/evaluator.ts @@ -320,47 +320,72 @@ export class ExpressionEvaluator { let depth = 0; let current = ""; let lastOp: "&&" | "||" | null = null; + let i = 0; - 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("&&")) { + 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 = "&&"; - // skip && + i += 2; continue; - } else if (remaining.startsWith("||")) { + } + if (expr.slice(i, i + 2) === "||") { if (current.trim()) { parts.push({ expr: current.trim(), op: lastOp }); current = ""; } lastOp = "||"; - // skip || + i += 2; continue; } } + current += char; + i++; + } + + if (current.trim()) { + parts.push({ expr: current.trim(), op: lastOp }); } - if (current.trim()) parts.push({ expr: current.trim(), op: lastOp }); // Simple case: single expression - if (parts.length === 0) return !!this.evaluateExpression(expr); + if (parts.length <= 1) { + return !!this.evaluateExpression(expr); + } - // Evaluate parts (supports &&, ||, ^ as xor) + // 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]; - const val = this.evaluateExpression(part.expr); if (part.op === "&&") { - result = result && val; + // 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 === "||") { - result = result || val; + // Short-circuit: if result is true, no need to evaluate further + if (result) return true; + result = result || this.evaluateExpression(part.expr); } } @@ -458,9 +483,33 @@ export class ExpressionEvaluator { 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); + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!this.deepEquals(a[i], b[i])) return false; + } + return true; + } + // Handle plain objects + if (!Array.isArray(a) && !Array.isArray(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!this.deepEquals((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; + } + // Mixed types (array vs object) + return false; } + return false; } diff --git a/src/yarn-spinner/runtime/results.ts b/src/yarn-spinner/runtime/results.ts index 2dd4c13..e0b9323 100644 --- a/src/yarn-spinner/runtime/results.ts +++ b/src/yarn-spinner/runtime/results.ts @@ -1,4 +1,8 @@ import type { MarkupParseResult } from "../markup/types"; + +/** + * Result emitted when the dialogue produces text/dialogue. + */ export type TextResult = { type: "text"; text: string; @@ -10,6 +14,9 @@ export type TextResult = { 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 }[]; @@ -18,11 +25,17 @@ export type OptionsResult = { isDialogueEnd: boolean; }; +/** + * Result emitted when the dialogue executes a command. + */ export type CommandResult = { type: "command"; command: string; isDialogueEnd: boolean; }; +/** + * Union type of all possible runtime results emitted by the YarnRunner. + */ export type RuntimeResult = TextResult | OptionsResult | CommandResult; diff --git a/src/yarn-spinner/runtime/runner.ts b/src/yarn-spinner/runtime/runner.ts index b54f8fb..b7f96d9 100644 --- a/src/yarn-spinner/runtime/runner.ts +++ b/src/yarn-spinner/runtime/runner.ts @@ -11,8 +11,14 @@ export interface RunnerOptions { handleCommand?: (command: string, parsed?: ReturnType) => void; commandHandler?: CommandHandler; onStoryEnd?: (payload: { variables: Readonly>; storyEnd: true }) => void; + /** + * If true, each runner instance maintains its own once-seen state. + * If false (default), all runners share global once-seen state. + */ + isolated?: boolean; } +// Global shared state for once-seen tracking (default behavior) const globalOnceSeen = new Set(); const globalNodeGroupOnceSeen = new Set(); // Track "once" nodes in groups: "title#index" @@ -25,26 +31,27 @@ type CompiledOption = { block: IRInstruction[]; }; +type CallStackFrame = + | { kind: "detour"; title: string; ip: number } + | { kind: "block"; title: string; ip: number; block: IRInstruction[]; idx: number }; + export class YarnRunner { private readonly program: IRProgram; private readonly variables: Record; private readonly handleCommand?: (command: string, parsed?: ReturnType) => void; private readonly commandHandler: CommandHandler; private readonly evaluator: ExpressionEvaluator; - private readonly onceSeen = globalOnceSeen; + private readonly onceSeen: Set; + private readonly nodeGroupOnceSeen: Set; private readonly onStoryEnd?: RunnerOptions["onStoryEnd"]; private storyEnded = false; - private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen; private readonly visitCounts: Record = {}; private pendingOptions: CompiledOption[] | null = null; private nodeTitle: string; private ip = 0; // instruction pointer within node private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node) - private callStack: Array< - | ({ title: string; ip: number } & { kind: "detour" }) - | ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" }) - > = []; + private callStack: CallStackFrame[] = []; currentResult: RuntimeResult | null = null; history: RuntimeResult[] = []; @@ -58,6 +65,14 @@ export class YarnRunner { this.variables[normalizedKey] = value; } } + // Use isolated state if requested, otherwise share global state + if (opts.isolated) { + this.onceSeen = new Set(); + this.nodeGroupOnceSeen = new Set(); + } else { + this.onceSeen = globalOnceSeen; + this.nodeGroupOnceSeen = globalNodeGroupOnceSeen; + } let functions = { // Default conversion helpers string: (v: unknown) => String(v ?? ""), @@ -229,6 +244,9 @@ export class YarnRunner { 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 { @@ -247,6 +265,17 @@ export class YarnRunner { 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) { @@ -275,29 +304,6 @@ export class YarnRunner { 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); @@ -306,17 +312,18 @@ export class YarnRunner { type: wrapper.type, properties: { ...wrapper.properties }, })); - if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) { + if (currentSegment && this.wrappersEqual(currentSegment.wrappers, wrappersCopy)) { currentSegment.end = index + 1; } else { - flushSegment(); + this.flushSegment(currentSegment, newSegments); currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy }; } }; const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => { if (!value) { - flushSegment(); + this.flushSegment(currentSegment, newSegments); + currentSegment = null; return; } for (const ch of value) { @@ -345,12 +352,34 @@ export class YarnRunner { i += 1; } - flushSegment(); + 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) { @@ -396,13 +425,12 @@ export class YarnRunner { 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); - } + 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; } @@ -475,13 +503,12 @@ export class YarnRunner { 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); - } + 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; } @@ -505,7 +532,10 @@ export class YarnRunner { } case "return": { const top = this.callStack.pop(); - if(!top) throw new Error("No call stack to return to"); + 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 @@ -560,7 +590,8 @@ export class YarnRunner { if (this.evaluator.evaluate(option.condition)) { available.push(option); } - } catch { + } catch (err) { + console.warn(`Option condition evaluation error: ${err}`); // Treat errors as false conditions } } @@ -568,15 +599,15 @@ export class YarnRunner { } private lookaheadIsEnd(): boolean { - // Check if current node has more emit-worthy instructions + // 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; } - // Node is ending - mark as end (will trigger detour return if callStack exists) return true; }