1
0
Fork 0
tsgames/src/common/rpg/dialog.ts

312 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);
const visited = new Set<string>();
while (targetId !== undefined) {
if (visited.has(targetId)) {
console.warn(`[DialogEngine] cycle detected at node '${targetId}', stopping`);
return null;
}
visited.add(targetId);
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;
}
}