Newer
Older
rpg / apps / game / src / data / validate.ts
import {
    checkBounds,
    checkWalkable,
    mergeInvariants,
    type Grid,
    type Invariant,
    type TileMapData,
    type DialogueGraph
} from '@rpg/engine';
import { LOCATIONS } from './locations';
import { NPCS } from './npcs';
import { DIALOGUES } from './dialogues';
import { ENEMY_KINDS } from './enemies';

/**
 * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна —
 * в TS-типах; здесь ловим то, что типы не выражают: ссылки, границы, стены).
 * Вызывается из GameAgent.invariants() и из юнит-теста — битый контент падает
 * сразу в тестах, а не в рантайме игры.
 */

const WHERE = 'data';

/** Grid-адаптер над сырыми данными карты (для checkBounds/checkWalkable/A*). */
function gridOf(data: TileMapData): Grid {
    return {
        width: data.width,
        height: data.height,
        isWalkable: (x, y) =>
            x >= 0 && y >= 0 && x < data.width && y < data.height &&
            !data.blocked.includes(data.tiles[y * data.width + x])
    };
}

/** Граф диалога: start/next/choices существуют, узлы-сироты (warn). */
export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] {
    const out: Invariant[] = [];
    const where = `${WHERE}/dialogue/${id}`;
    if (!graph.nodes[graph.start]) {
        out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where });
    }
    for (const [nodeId, node] of Object.entries(graph.nodes)) {
        for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) {
            if (next !== undefined && !graph.nodes[next]) {
                out.push({ id: 'dialogue-next', severity: 'error', message: `узел «${nodeId}»: next «${next}» не существует`, where });
            }
        }
    }
    // Сироты: узлы, в которые нет ссылки (warn — могут быть «мёртвым ветвлением»).
    const referenced = new Set<string>([graph.start]);
    for (const node of Object.values(graph.nodes)) {
        for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) {
            if (next !== undefined) referenced.add(next);
        }
    }
    for (const nodeId of Object.keys(graph.nodes)) {
        if (!referenced.has(nodeId)) {
            out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${nodeId}» недостижим`, where });
        }
    }
    return out;
}

/** NPC: не в стенах, в границах своей локации. */
export function validateNpcs(maps: Map<string, TileMapData>): Invariant[] {
    const out: Invariant[] = [];
    for (const npc of NPCS) {
        for (const loc of Object.values(LOCATIONS)) {
            if (!loc.npcs.some((n) => n.id === npc.id)) continue;
            const map = maps.get(loc.id);
            if (!map) continue;
            const grid = gridOf(map);
            const check = mergeInvariants(
                checkBounds(`NPC ${npc.id}`, npc.tile, map.width, map.height, WHERE),
                checkWalkable(`NPC ${npc.id}`, npc.tile, grid, WHERE)
            );
            out.push(...check);
        }
    }
    return out;
}

/** Локации: спавн/враги проходимы, exits ведут в существующие области и проходимые entry. */
export function validateLocations(maps: Map<string, TileMapData>): Invariant[] {
    const out: Invariant[] = [];
    for (const loc of Object.values(LOCATIONS)) {
        const map = maps.get(loc.id);
        if (!map) continue;
        const grid = gridOf(map);
        const where = `${WHERE}/location/${loc.id}`;
        out.push(...mergeInvariants(
            checkBounds('spawn', loc.spawn, map.width, map.height, where),
            checkWalkable('spawn', loc.spawn, grid, where)
        ));
        loc.enemies.forEach((e, i) => {
            out.push(...mergeInvariants(
                checkBounds(`враг#${i}`, e.tile, map.width, map.height, where),
                checkWalkable(`враг#${i}`, e.tile, grid, where)
            ));
        });
        for (const exit of loc.exits) {
            if (!LOCATIONS[exit.to]) {
                out.push({ id: 'exit-target', severity: 'error', message: `exit -> «${exit.to}»: локация не существует`, where });
                continue;
            }
            const targetMap = maps.get(exit.to);
            if (targetMap) {
                out.push(...mergeInvariants(
                    checkBounds(`exit entry (${exit.to})`, exit.entry, targetMap.width, targetMap.height, where),
                    checkWalkable(`exit entry (${exit.to})`, exit.entry, gridOf(targetMap), where)
                ));
            }
        }
    }
    return out;
}

/** Виды врагов: базовая числовая согласованность. */
export function validateEnemies(): Invariant[] {
    const out: Invariant[] = [];
    for (const kind of Object.values(ENEMY_KINDS)) {
        const where = `${WHERE}/enemy/${kind.id}`;
        if (kind.hp <= 0) out.push({ id: 'enemy-hp', severity: 'error', message: `hp = ${kind.hp}`, where });
        if (kind.speed < 0) out.push({ id: 'enemy-speed', severity: 'error', message: `speed = ${kind.speed}`, where });
    }
    return out;
}

/** Весь контент разом (для снапшота моста и тестов). */
export function validateContent(maps: Map<string, TileMapData>): Invariant[] {
    return mergeInvariants(
        ...Object.entries(DIALOGUES).map(([id, g]) => validateDialogue(id, g)),
        validateNpcs(maps),
        validateLocations(maps),
        validateEnemies()
    );
}