1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Pabloader ad327b17d7 Dialog coverage test 2026-04-26 14:08:32 +00:00
Pabloader 8fd0d317cf Dialogs engine 2026-04-24 16:53:18 +00:00
5 changed files with 473 additions and 33 deletions

339
src/common/rpg/dialog.ts Normal file
View File

@ -0,0 +1,339 @@
import { isDialog, type Dialog, type DialogChoice, type DialogNode } from "./types";
type ConditionOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'null' | '~null';
type ConditionValue = string | number | boolean | null;
interface ParsedCondition {
variable: string;
negate: boolean;
operator?: ConditionOperator;
value?: ConditionValue;
}
function parseCondition(s: string): ParsedCondition {
// ~variable — falsy check, nothing else allowed
if (s.startsWith('~') && !s.includes(' '))
return { variable: s.slice(1), negate: true };
const spaceIdx = s.indexOf(' ');
if (spaceIdx === -1)
return { variable: s, negate: false };
const variable = s.slice(0, spaceIdx);
const rest = s.slice(spaceIdx + 1).trim();
if (rest === 'null') return { variable, negate: false, operator: 'null' };
if (rest === '~null') return { variable, negate: false, operator: '~null' };
const opMatch = rest.match(/^(==|!=|>=|<=|>|<)\s*(.+)$/);
if (!opMatch) throw new Error(`Invalid condition: "${s}"`);
const [, operator, rawValue] = opMatch;
let value: ConditionValue;
if (rawValue === 'null') value = null;
else if (rawValue === 'true') value = true;
else if (rawValue === 'false') value = false;
else if (!isNaN(Number(rawValue))) value = Number(rawValue);
else value = rawValue.replace(/^['"]|['"]$/g, '');
return { variable, negate: false, operator: operator as ConditionOperator, value };
}
export type DialogVariables = Record<string, string | number | boolean>;
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
export interface DialogRuntimeOptions {
variables: DialogVariables;
actions: DialogActions;
}
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);
}
}
}
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 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 {
private readonly nodeMap: Map<string, DialogNode>;
private readonly startNodeId: string;
private currentNode: DialogNode | null = null;
constructor(
dialog: Dialog,
private readonly options: DialogRuntimeOptions,
) {
this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
this.startNodeId = dialog.startNodeId;
}
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;
if (!this.evaluateConditions(node.conditions ?? [])) {
targetId = node.nextNodeId;
continue;
}
this.currentNode = node;
for (const action of node.actions ?? []) {
await this.options.actions[action.type]?.(action.arg);
}
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;
return choices.filter(choice => {
if (choice.nextNodeId === undefined) return true;
const target = this.nodeMap.get(choice.nextNodeId);
return target ? this.evaluateConditions(target.conditions ?? []) : false;
});
}
private evaluateConditions(conditions: string[]): boolean {
return conditions.every(c => this.evaluateCondition(parseCondition(c)));
}
private evaluateCondition({ variable, negate, operator, value }: ParsedCondition): boolean {
const val = this.options.variables[variable];
if (operator === 'null') return val == null;
if (operator === '~null') return val != null;
if (operator === undefined)
return negate ? !Boolean(val) : Boolean(val);
if (operator === '==') return val === value;
if (operator === '!=') return val !== value;
if (typeof val !== 'number' || typeof value !== 'number') return false;
if (operator === '<') return val < value;
if (operator === '>') return val > value;
if (operator === '<=') return val <= value;
if (operator === '>=') return val >= value;
return 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;
}
}

55
src/common/rpg/types.ts Normal file
View File

@ -0,0 +1,55 @@
export interface DialogAction {
type: string;
arg?: string | number | boolean | null;
}
export interface DialogChoice {
text: string;
nextNodeId?: string;
}
export interface DialogNode {
id: string;
speaker: string;
text: string;
nextNodeId?: string;
choices?: DialogChoice[];
conditions?: string[];
actions?: DialogAction[];
}
export interface Dialog {
nodes: DialogNode[];
startNodeId: string;
}
function isDialogAction(v: unknown): v is DialogAction {
if (typeof v !== 'object' || v === null) return false;
return typeof (v as Record<string, unknown>).type === 'string';
}
function isDialogChoice(v: unknown): v is DialogChoice {
if (typeof v !== 'object' || v === null) return false;
const c = v as Record<string, unknown>;
return typeof c.text === 'string'
&& (c.nextNodeId === undefined || typeof c.nextNodeId === 'string');
}
function isDialogNode(v: unknown): v is DialogNode {
if (typeof v !== 'object' || v === null) return false;
const n = v as Record<string, unknown>;
return typeof n.id === 'string'
&& typeof n.speaker === 'string'
&& typeof n.text === 'string'
&& (n.nextNodeId === undefined || typeof n.nextNodeId === 'string')
&& (n.choices === undefined || (Array.isArray(n.choices) && n.choices.every(isDialogChoice)))
&& (n.conditions === undefined || (Array.isArray(n.conditions) && n.conditions.every(v => typeof v === 'string')))
&& (n.actions === undefined || (Array.isArray(n.actions) && n.actions.every(isDialogAction)));
}
export function isDialog(v: unknown): v is Dialog {
if (typeof v !== 'object' || v === null) return false;
const d = v as Record<string, unknown>;
return Array.isArray(d.nodes) && d.nodes.every(isDialogNode)
&& typeof d.startNodeId === 'string';
}

2
src/common/types.ts Normal file
View File

@ -0,0 +1,2 @@
export type Key<T> = keyof T;
export type Value<T, K extends Key<T> = Key<T>> = T[K];

View File

@ -0,0 +1,68 @@
startNodeId: start
nodes:
- id: start
speaker: npc
text: "Ah, traveler. I sense you seek passage through my gates."
nextNodeId: sword_path
- id: sword_path
speaker: npc
text: "I see you carry a blade. Warriors are always welcome here."
nextNodeId: no_sword_path
conditions:
- hasSword
- id: no_sword_path
speaker: npc
text: "You come unarmed? Brave, or perhaps foolish. You may still pass."
nextNodeId: end
conditions:
- ~hasSword
- id: end
speaker: npc
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."

View File

@ -1,34 +1,10 @@
import { gameLoop } from "@common/game";
import Input from "@common/input";
import { DialogEngine, Dialogs } from "@common/rpg/dialog";
import dialogYml from './dialog.yml';
import { isDialog } from "@common/rpg/types";
const setup = () => {
let x = window.innerWidth / 2 - 16;
let y = window.innerHeight / 2 - 16;
const ball = document.createElement('div');
ball.style.display = 'block';
ball.style.width = '32px';
ball.style.height = '32px';
ball.style.borderRadius = '50%';
ball.style.backgroundColor = 'red';
ball.style.position = 'absolute';
document.body.append(ball);
const speed = Math.min(window.innerHeight, window.innerWidth);
return { x, y, speed, ball };
}
const frame = (dt: number, state: ReturnType<typeof setup>) => {
const dx = Input.getHorizontal();
const dy = Input.getVertical();
state.x += state.speed * dx * dt;
state.y += state.speed * dy * dt;
state.ball.style.left = `${state.x}px`;
state.ball.style.top = `${state.y}px`;
}
export default gameLoop(setup, frame);
export default async function main() {
// console.log(dialogYml);
if (isDialog(dialogYml)) {
console.log(await Dialogs.coverageTest(dialogYml));
}
}