From ad327b17d766ae8bebcde2293c67ce315cfbe467 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Sun, 26 Apr 2026 14:08:32 +0000 Subject: [PATCH] Dialog coverage test --- src/common/rpg/dialog.ts | 245 +++++++++++++++++++++++++------- src/games/playground/dialog.yml | 41 ++++++ src/games/playground/index.tsx | 14 +- 3 files changed, 237 insertions(+), 63 deletions(-) diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index 21dce61..9ba1cda 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -39,12 +39,6 @@ function parseCondition(s: string): ParsedCondition { 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; export type DialogActions = Record Promise | void>; @@ -54,60 +48,196 @@ export interface DialogRuntimeOptions { } export interface DialogNodeView { + id: string; + nextNodeId?: string; speaker: string; text: string; choices?: DialogChoice[]; } -export function validateDialog( - dialog: unknown, - conditions: string[], - 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 conditionSet = new Set(conditions); - 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 cond of node.conditions ?? []) { - const varName = conditionVariable(cond); - if (!conditionSet.has(varName)) - errors.push(`node '${node.id}': unknown condition variable '${varName}'`); +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); + } } - - 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 Array.from(conditions.values()); } - return errors; -} + 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); + } + } + } -export function isValidDialog( - v: unknown, - conditions: string[], - actions: string[], - speakers: string[], -): v is Dialog { - return validateDialog(v, conditions, actions, speakers).length === 0; + 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 DialogVariables; + + 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 { @@ -144,6 +274,8 @@ export class DialogEngine { const choices = this.filterChoices(node.choices); return { + id: node.id, + nextNodeId: node.nextNodeId, speaker: node.speaker, text: node.text, choices, @@ -155,12 +287,11 @@ export class DialogEngine { private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined { if (!choices) return undefined; - const filtered = choices.filter(choice => { + return choices.filter(choice => { if (choice.nextNodeId === undefined) return true; const target = this.nodeMap.get(choice.nextNodeId); return target ? this.evaluateConditions(target.conditions ?? []) : false; }); - return filtered.length > 0 ? filtered : undefined; } private evaluateConditions(conditions: string[]): boolean { @@ -197,4 +328,12 @@ export class DialogEngine { 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; + } } \ No newline at end of file diff --git a/src/games/playground/dialog.yml b/src/games/playground/dialog.yml index e287902..07ae243 100644 --- a/src/games/playground/dialog.yml +++ b/src/games/playground/dialog.yml @@ -25,3 +25,44 @@ nodes: text: "Safe travels." choices: - 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." diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index eb3eedf..44f39cf 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,16 +1,10 @@ -import { DialogEngine, isValidDialog } from "@common/rpg/dialog"; +import { DialogEngine, Dialogs } from "@common/rpg/dialog"; import dialogYml from './dialog.yml'; +import { isDialog } from "@common/rpg/types"; export default async function main() { // console.log(dialogYml); - if (isValidDialog(dialogYml, ['hasSword'], [], ['player', 'npc'])) { - const dialog = new DialogEngine(dialogYml, { - variables: { hasSword: false }, - actions: {}, - }); - // console.log(await dialog.advance()); - for await (const node of dialog) { - console.log(node); - } + if (isDialog(dialogYml)) { + console.log(await Dialogs.coverageTest(dialogYml)); } } \ No newline at end of file