import { isDialog, type Dialog, type DialogChoice, type DialogNode, type RPGActions, type RPGVariables, } from "./types"; import { evaluateConditions, parseCondition, type ParsedCondition, } from "./utils/conditions"; import { isEvalContext, type EvalContext } from "./core/world"; import { resolveVariables, executeAction } from "./utils/variables"; export interface DialogRuntimeOptions { variables: RPGVariables; actions: RPGActions; } export interface DialogNodeView { id: string; nextNodeId?: string; speaker: string; text: string; choices?: DialogChoice[]; } export namespace Dialogs { export function getConditions(dialog: Dialog): ParsedCondition[] { const conditions = new Map(); for (const node of dialog.nodes) { for (const condition of node.conditions ?? []) { const parsed = parseCondition(condition); conditions.set(condition, parsed); } } return Array.from(conditions.values()); } export function getPossibleActions(dialog: Dialog) { const actions = new Set(); for (const node of dialog.nodes) { for (const action of node.actions ?? []) { actions.add(action.type); } } return Array.from(actions); } export function validate( dialog: unknown, actions: string[], speakers: string[], ): string[] { if (!isDialog(dialog)) return ['invalid dialog structure']; const errors: string[] = []; const nodeIds = new Set(dialog.nodes.map(n => n.id)); const actionSet = new Set(actions); const speakerSet = new Set(speakers); if (!nodeIds.has(dialog.startNodeId)) errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`); for (const node of dialog.nodes) { if (!speakerSet.has(node.speaker)) { errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`); } if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId)) errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`); for (const action of node.actions ?? []) { if (!actionSet.has(action.type)) { errors.push(`node '${node.id}': unknown action type '${action.type}'`); } } for (const choice of node.choices ?? []) { if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId)) { errors.push(`node '${node.id}': choice '${choice.text}': nextNodeId '${choice.nextNodeId}' does not match any node id`); } } } return errors; } export function isValid( v: unknown, actions: string[], speakers: string[], ): v is Dialog { return validate(v, actions, speakers).length === 0; } type TestValue = string | number | boolean | undefined; type TestVariables = Record; export interface CoverageIssue { nodeId: string; description: string; combinations: TestVariables[]; } function inferCandidateValues(conditions: ParsedCondition[]): Map { const map = new Map(); const add = (variable: string, ...values: TestValue[]) => { const existing = map.get(variable) ?? []; for (const v of values) if (!existing.includes(v)) existing.push(v); map.set(variable, existing); }; for (const { variable, operator, value } of conditions) { if (operator === undefined) { add(variable, undefined, true, false); } else if (operator === 'null' || operator === '~null') { add(variable, undefined, true); } else if (operator === '==' || operator === '!=') { let other: TestValue; if (typeof value === 'number') other = value + 1; else if (typeof value === 'string') other = value === '' ? '_' : ''; else if (typeof value === 'boolean') other = !value; else other = true; add(variable, value as TestValue, other); } else { const n = value as number; add(variable, n - 1, n, n + 1); } } return map; } function cartesianProduct(map: Map): TestVariables[] { const entries = Array.from(map.entries()); let results: TestVariables[] = [{}]; for (const [variable, values] of entries) { const next: TestVariables[] = []; for (const vars of results) { for (const value of values) { next.push({ ...vars, [variable]: value }); } } results = next; } return results; } export async function coverageTest(dialog: Dialog): Promise { const conditions = getConditions(dialog); const candidateMap = inferCandidateValues(conditions); if (candidateMap.size === 0) candidateMap.set('__dummy__', [undefined]); const combinations = cartesianProduct(candidateMap); const visited = new Set(); const issueMap = new Map(); for (const combo of combinations) { const variables = Object.fromEntries( Object.entries(combo).filter(([, v]) => v !== undefined) ) as RPGVariables; const engine = new DialogEngine(dialog, { variables, actions: {} }); const stack: string[] = [dialog.startNodeId]; const seen = new Set(); while (stack.length > 0) { const nodeId = stack.pop()!; if (seen.has(nodeId)) continue; seen.add(nodeId); const view = await engine.advance(nodeId); if (view === null) continue; visited.add(view.id); if (view.choices !== undefined) { if (view.choices.length === 0) { const existing = issueMap.get(view.id); if (existing) { existing.combinations.push(combo); } else { issueMap.set(view.id, { nodeId: view.id, description: 'all choices eliminated', combinations: [combo], }); } } else { for (const choice of view.choices) if (choice.nextNodeId !== undefined) stack.push(choice.nextNodeId); } } else if (view.nextNodeId !== undefined) { stack.push(view.nextNodeId); } } } const unreachable = dialog.nodes .filter(n => !visited.has(n.id)) .map(n => ({ nodeId: n.id, description: 'unreachable', combinations: [] as TestVariables[] })); return [...issueMap.values(), ...unreachable]; } } export class DialogEngine { private readonly nodeMap: Map; private readonly startNodeId: string; private currentNode: DialogNode | null = null; constructor( dialog: Dialog, private readonly options: DialogRuntimeOptions | EvalContext, ) { this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n])); this.startNodeId = dialog.startNodeId; } private getVariables(): RPGVariables { if (isEvalContext(this.options)) return resolveVariables(this.options.self); return this.options.variables; } private async runAction(action: { type: string; arg?: string | number | boolean }): Promise { if (isEvalContext(this.options)) { await executeAction(action, this.options); } else { await this.options.actions[action.type]?.(action.arg); } } async advance(nodeId?: string): Promise { let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined); while (targetId !== undefined) { const node = this.nodeMap.get(targetId); if (!node) return null; const vars = this.getVariables(); if (!evaluateConditions(node.conditions ?? [], vars)) { targetId = node.nextNodeId; continue; } this.currentNode = node; for (const action of node.actions ?? []) { await this.runAction(action); } const choices = this.filterChoices(node.choices); return { id: node.id, nextNodeId: node.nextNodeId, speaker: node.speaker, text: node.text, choices, }; } return null; } private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined { if (!choices) return undefined; const vars = this.getVariables(); return choices.filter(choice => { if (choice.nextNodeId === undefined) return true; const target = this.nodeMap.get(choice.nextNodeId); return target ? evaluateConditions(target.conditions ?? [], vars) : false; }); } [Symbol.asyncIterator]() { return this; } async next() { const nextNode = await this.advance(); if (nextNode === null) return { done: true, value: null }; return { done: false, value: nextNode }; } reset() { this.currentNode = null; } get currentSpeaker(): string | null { return this.currentNode?.speaker || null; } }