Dialog coverage test
This commit is contained in:
parent
8fd0d317cf
commit
ad327b17d7
|
|
@ -39,12 +39,6 @@ function parseCondition(s: string): ParsedCondition {
|
||||||
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
return { variable, negate: false, operator: operator as ConditionOperator, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
function conditionVariable(s: string): string {
|
|
||||||
if (s.startsWith('~')) return s.slice(1);
|
|
||||||
const space = s.indexOf(' ');
|
|
||||||
return space === -1 ? s : s.slice(0, space);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DialogVariables = Record<string, string | number | boolean>;
|
export type DialogVariables = Record<string, string | number | boolean>;
|
||||||
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
||||||
|
|
||||||
|
|
@ -54,14 +48,36 @@ export interface DialogRuntimeOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogNodeView {
|
export interface DialogNodeView {
|
||||||
|
id: string;
|
||||||
|
nextNodeId?: string;
|
||||||
speaker: string;
|
speaker: string;
|
||||||
text: string;
|
text: string;
|
||||||
choices?: DialogChoice[];
|
choices?: DialogChoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDialog(
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(
|
||||||
dialog: unknown,
|
dialog: unknown,
|
||||||
conditions: string[],
|
|
||||||
actions: string[],
|
actions: string[],
|
||||||
speakers: string[],
|
speakers: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -69,7 +85,6 @@ export function validateDialog(
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const nodeIds = new Set(dialog.nodes.map(n => n.id));
|
const nodeIds = new Set(dialog.nodes.map(n => n.id));
|
||||||
const conditionSet = new Set(conditions);
|
|
||||||
const actionSet = new Set(actions);
|
const actionSet = new Set(actions);
|
||||||
const speakerSet = new Set(speakers);
|
const speakerSet = new Set(speakers);
|
||||||
|
|
||||||
|
|
@ -77,37 +92,152 @@ export function validateDialog(
|
||||||
errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`);
|
errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`);
|
||||||
|
|
||||||
for (const node of dialog.nodes) {
|
for (const node of dialog.nodes) {
|
||||||
if (!speakerSet.has(node.speaker))
|
if (!speakerSet.has(node.speaker)) {
|
||||||
errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`);
|
errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`);
|
||||||
|
}
|
||||||
|
|
||||||
if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId))
|
if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId))
|
||||||
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
|
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
|
||||||
|
|
||||||
for (const cond of node.conditions ?? []) {
|
for (const action of node.actions ?? []) {
|
||||||
const varName = conditionVariable(cond);
|
if (!actionSet.has(action.type)) {
|
||||||
if (!conditionSet.has(varName))
|
errors.push(`node '${node.id}': unknown action type '${action.type}'`);
|
||||||
errors.push(`node '${node.id}': unknown condition variable '${varName}'`);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const action of node.actions ?? [])
|
for (const choice of node.choices ?? []) {
|
||||||
if (!actionSet.has(action.type))
|
if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId)) {
|
||||||
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`);
|
errors.push(`node '${node.id}': choice '${choice.text}': nextNodeId '${choice.nextNodeId}' does not match any node id`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidDialog(
|
export function isValid(
|
||||||
v: unknown,
|
v: unknown,
|
||||||
conditions: string[],
|
|
||||||
actions: string[],
|
actions: string[],
|
||||||
speakers: string[],
|
speakers: string[],
|
||||||
): v is Dialog {
|
): v is Dialog {
|
||||||
return validateDialog(v, conditions, actions, speakers).length === 0;
|
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 DialogVariables;
|
||||||
|
|
||||||
|
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 {
|
export class DialogEngine {
|
||||||
|
|
@ -144,6 +274,8 @@ export class DialogEngine {
|
||||||
const choices = this.filterChoices(node.choices);
|
const choices = this.filterChoices(node.choices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: node.id,
|
||||||
|
nextNodeId: node.nextNodeId,
|
||||||
speaker: node.speaker,
|
speaker: node.speaker,
|
||||||
text: node.text,
|
text: node.text,
|
||||||
choices,
|
choices,
|
||||||
|
|
@ -155,12 +287,11 @@ export class DialogEngine {
|
||||||
|
|
||||||
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
|
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
|
||||||
if (!choices) return undefined;
|
if (!choices) return undefined;
|
||||||
const filtered = choices.filter(choice => {
|
return choices.filter(choice => {
|
||||||
if (choice.nextNodeId === undefined) return true;
|
if (choice.nextNodeId === undefined) return true;
|
||||||
const target = this.nodeMap.get(choice.nextNodeId);
|
const target = this.nodeMap.get(choice.nextNodeId);
|
||||||
return target ? this.evaluateConditions(target.conditions ?? []) : false;
|
return target ? this.evaluateConditions(target.conditions ?? []) : false;
|
||||||
});
|
});
|
||||||
return filtered.length > 0 ? filtered : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateConditions(conditions: string[]): boolean {
|
private evaluateConditions(conditions: string[]): boolean {
|
||||||
|
|
@ -197,4 +328,12 @@ export class DialogEngine {
|
||||||
if (nextNode === null) return { done: true, value: null };
|
if (nextNode === null) return { done: true, value: null };
|
||||||
return { done: false, value: nextNode };
|
return { done: false, value: nextNode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.currentNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentSpeaker(): string | null {
|
||||||
|
return this.currentNode?.speaker || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,3 +25,44 @@ nodes:
|
||||||
text: "Safe travels."
|
text: "Safe travels."
|
||||||
choices:
|
choices:
|
||||||
- text: "Farewell."
|
- text: "Farewell."
|
||||||
|
nextNodeId: dead_choice
|
||||||
|
|
||||||
|
# reachable, but all choices point to guard_fight which requires hasSword — eliminated when hasSword=false
|
||||||
|
- id: dead_choice
|
||||||
|
speaker: npc
|
||||||
|
text: "One last thing — do you wish to duel my guard?"
|
||||||
|
choices:
|
||||||
|
- text: "Challenge the guard."
|
||||||
|
nextNodeId: guard_fight
|
||||||
|
- text: "Slip past."
|
||||||
|
nextNodeId: guard_fight
|
||||||
|
|
||||||
|
- id: guard_fight
|
||||||
|
speaker: npc
|
||||||
|
text: "The guard steps forward."
|
||||||
|
conditions:
|
||||||
|
- hasSword
|
||||||
|
nextNodeId: vault_gate
|
||||||
|
|
||||||
|
# vault_gate is shown only when level >= 42, but vault requires level < 40 — mutually exclusive
|
||||||
|
- id: vault_gate
|
||||||
|
speaker: npc
|
||||||
|
text: "Beyond lies an ancient vault. Only the truly powerful may enter."
|
||||||
|
conditions:
|
||||||
|
- level >= 42
|
||||||
|
choices:
|
||||||
|
- text: "Enter the vault."
|
||||||
|
nextNodeId: vault
|
||||||
|
- text: "Turn back."
|
||||||
|
nextNodeId: start
|
||||||
|
|
||||||
|
- id: vault
|
||||||
|
speaker: npc
|
||||||
|
text: "You enter the vault."
|
||||||
|
conditions:
|
||||||
|
- level < 40
|
||||||
|
|
||||||
|
# orphan — nothing points here
|
||||||
|
- id: orphan
|
||||||
|
speaker: npc
|
||||||
|
text: "You should never see this."
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
import { DialogEngine, isValidDialog } from "@common/rpg/dialog";
|
import { DialogEngine, Dialogs } from "@common/rpg/dialog";
|
||||||
import dialogYml from './dialog.yml';
|
import dialogYml from './dialog.yml';
|
||||||
|
import { isDialog } from "@common/rpg/types";
|
||||||
|
|
||||||
export default async function main() {
|
export default async function main() {
|
||||||
// console.log(dialogYml);
|
// console.log(dialogYml);
|
||||||
if (isValidDialog(dialogYml, ['hasSword'], [], ['player', 'npc'])) {
|
if (isDialog(dialogYml)) {
|
||||||
const dialog = new DialogEngine(dialogYml, {
|
console.log(await Dialogs.coverageTest(dialogYml));
|
||||||
variables: { hasSword: false },
|
|
||||||
actions: {},
|
|
||||||
});
|
|
||||||
// console.log(await dialog.advance());
|
|
||||||
for await (const node of dialog) {
|
|
||||||
console.log(node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue