305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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<string, ParsedCondition>();
|
|
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<string>();
|
|
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<string, TestValue>;
|
|
|
|
export interface CoverageIssue {
|
|
nodeId: string;
|
|
description: string;
|
|
combinations: TestVariables[];
|
|
}
|
|
|
|
function inferCandidateValues(conditions: ParsedCondition[]): Map<string, TestValue[]> {
|
|
const map = new Map<string, TestValue[]>();
|
|
|
|
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<string, TestValue[]>): 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<CoverageIssue[]> {
|
|
const conditions = getConditions(dialog);
|
|
const candidateMap = inferCandidateValues(conditions);
|
|
|
|
if (candidateMap.size === 0)
|
|
candidateMap.set('__dummy__', [undefined]);
|
|
|
|
const combinations = cartesianProduct(candidateMap);
|
|
const visited = new Set<string>();
|
|
const issueMap = new Map<string, CoverageIssue>();
|
|
|
|
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<string>();
|
|
|
|
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<string, DialogNode>;
|
|
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<void> {
|
|
if (isEvalContext(this.options)) {
|
|
await executeAction(action, this.options);
|
|
} else {
|
|
await this.options.actions[action.type]?.(action.arg);
|
|
}
|
|
}
|
|
|
|
async advance(nodeId?: string): Promise<DialogNodeView | null> {
|
|
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;
|
|
}
|
|
}
|