refactor: ai's attempt
This commit is contained in:
parent
e13ead2309
commit
f1aa536c4d
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Mixed types (array vs object)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,14 @@ export interface RunnerOptions {
|
|||
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"
|
||||
|
||||
|
|
@ -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<string, 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 onceSeen: Set<string>;
|
||||
private readonly nodeGroupOnceSeen: Set<string>;
|
||||
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" })
|
||||
> = [];
|
||||
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<string>();
|
||||
this.nodeGroupOnceSeen = new Set<string>();
|
||||
} 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(() => {});
|
||||
// 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);
|
||||
} catch {
|
||||
if (this.handleCommand) this.handleCommand(ins.content);
|
||||
}
|
||||
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(() => {});
|
||||
// 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);
|
||||
} catch {
|
||||
if (this.handleCommand) this.handleCommand(ins.content);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue