Newer
Older
rpg / apps / game / src / data / validate.ts
import {
    checkBounds,
    checkWalkable,
    gridOf,
    mergeInvariants,
    type Invariant,
    type TileMapData,
    type DialogueGraph
} from '@rpg/engine';
import { AREAS } from './locations';
import { NPCS } from './npcs';
import { DIALOGUES } from './dialogues';
import { ENEMY_KINDS } from './enemies';
import { QUESTS } from './quests';
import { FLAGS, VARS } from './ids';
import { ITEMS } from './items';
import { TILES } from './map';
import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from './audio';
import { THEMES } from './music';
import { ROOM_TONES, SPEC_SFX } from './sfxSpecs';
import { DIALOGUE_CUSTOM } from './effects';
import { checkGraph, type GraphRefs } from './dialogueRules';

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

const WHERE = 'data';

/** Реестры для checkGraph (чистые правила знают только множества id). */
export function graphRefs(usedFlags?: Set<string>, usedVars?: Set<string>): GraphRefs {
    return {
        flags: new Set(Object.keys(FLAGS)),
        vars: new Set(Object.keys(VARS)),
        items: new Set(Object.keys(ITEMS)),
        customs: new Set(Object.keys(DIALOGUE_CUSTOM)),
        strings: new Set(),
        usedFlags,
        usedVars
    };
}

/** Граф диалога: все правила checkGraph (ссылки, next, сироты, циклы, концы). */
export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] {
    return checkGraph(id, graph, graphRefs());
}

/** NPC: не в стенах, в границах своей области. */
export function validateNpcs(maps: Map<string, TileMapData>): Invariant[] {
    const out: Invariant[] = [];
    for (const npc of NPCS) {
        for (const area of Object.values(AREAS)) {
            if (!area.npcs.some((n) => n.id === npc.id)) continue;
            const map = maps.get(area.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;
}

/** Области: спавн/враги проходимы, переходы ведут в существующие области и проходимые entry. */
export function validateLocations(maps: Map<string, TileMapData>): Invariant[] {
    const out: Invariant[] = [];
    for (const loc of Object.values(AREAS)) {
        const map = maps.get(loc.id);
        if (!map) continue;
        const grid = gridOf(map);
        const where = `${WHERE}/area/${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)
            ));
            // Патруль: точки в границах и проходимы (иначе враг врежется в стену).
            (e.patrol?.points ?? []).forEach((p, j) => {
                out.push(...mergeInvariants(
                    checkBounds(`враг#${i} патруль#${j}`, p, map.width, map.height, where),
                    checkWalkable(`враг#${i} патруль#${j}`, p, grid, where)
                ));
            });
        });
        // Зоны наката: тайлы в границах карты.
        for (const [i, h] of (loc.hazards ?? []).entries()) {
            h.tiles.forEach((t, j) => {
                out.push(...mergeInvariants(checkBounds(`накат#${i} тайл#${j}`, t, map.width, map.height, where)));
            });
        }
        // Источники света: центр в границах (дробные at — содержащий тайл),
        // id тайлов-светильников существуют; дубли id в одной области — ошибка.
        const lightIds = new Set<string>();
        for (const [i, s] of (loc.lighting?.sources ?? []).entries()) {
            const lw = `${where}/light#${i}`;
            if (lightIds.has(s.id)) {
                out.push({ id: 'light-id', severity: 'error', message: `источник света «${s.id}» уже есть в области`, where: lw });
            }
            lightIds.add(s.id);
            if (s.at) out.push(...mergeInvariants(checkBounds(`light#${i} at`, { x: Math.floor(s.at.x), y: Math.floor(s.at.y) }, map.width, map.height, lw)));
            for (const tileId of s.tiles ?? []) {
                if (!(Object.values(TILES) as number[]).includes(tileId)) {
                    out.push({ id: 'light-tile', severity: 'error', message: `light#${i}: тайл ${tileId} не из TILES`, where: lw });
                }
            }
            if (!s.at && !(s.tiles && s.tiles.length > 0)) {
                out.push({ id: 'light-source-empty', severity: 'error', message: `light#${i} «${s.id}»: ни at, ни tiles — источник никогда не включится`, where: lw });
            }
        }
        for (const obj of loc.interactables ?? []) {
            out.push(...mergeInvariants(checkBounds(`объект ${obj.id}`, obj.tile, map.width, map.height, where)));
        }
        for (const [i, t] of loc.transitions.entries()) {
            // Тайл-триггер в границах своей карты (может быть и непроходим — колодец).
            out.push(...mergeInvariants(checkBounds(`переход#${i}`, t.tile, map.width, map.height, where)));
            if (t.target.kind !== 'area') continue;
            const to: string = t.target.area;
            if (!(to in AREAS)) {
                out.push({ id: 'transition-target', severity: 'error', message: `переход#${i} -> «${to}»: область не существует`, where });
                continue;
            }
            const targetMap = maps.get(to);
            if (targetMap) {
                out.push(...mergeInvariants(
                    checkBounds(`переход#${i} entry (${to})`, t.target.entry, targetMap.width, targetMap.height, where),
                    checkWalkable(`переход#${i} entry (${to})`, t.target.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;
}

/**
 * Референциальная целостность строковых сущностей: каждое упоминание флага/
 * вара в контенте существует в реестрах ids.ts (error), каждый ключ реестра
 * где-то упомянут (warn — «мёртвый» флаг); ссылки квестов на NPC/диалоги —
 * error. Движковые типы — строки, поэтому реестр сверяется только здесь.
 */
export function validateReferences(): Invariant[] {
    const out: Invariant[] = [];
    const flagsUsed = new Set<string>();
    const varsUsed = new Set<string>();

    const checkFlag = (flag: string, where: string, what: string): void => {
        flagsUsed.add(flag);
        if (!(flag in FLAGS)) {
            out.push({ id: 'flag-unknown', severity: 'error', message: `${what}: флаг «${flag}» вне реестра FLAGS`, where });
        }
    };
    const checkVar = (id: string, where: string, what: string): void => {
        varsUsed.add(id);
        if (!(id in VARS)) {
            out.push({ id: 'var-unknown', severity: 'error', message: `${what}: вар «${id}» вне реестра VARS`, where });
        }
    };

    // Графы диалогов — общие правила checkGraph (и упоминания для dead-проверки).
    for (const [id, graph] of Object.entries(DIALOGUES)) {
        out.push(...checkGraph(id, graph, graphRefs(flagsUsed, varsUsed)));
    }
    for (const area of Object.values(AREAS)) {
        const where = `${WHERE}/area/${area.id}`;
        for (const [i, t] of area.transitions.entries()) {
            const tw = `переход#${i} (${t.tile.x},${t.tile.y})`;
            if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, tw);
            if (t.requiresItem !== undefined && !(t.requiresItem in ITEMS)) {
                out.push({ id: 'item-unknown', severity: 'error', message: `${tw}: requiresItem «${t.requiresItem}» вне реестра ITEMS`, where });
            }
        }
        // Условные источники света: опечатка во флаге = свет никогда не включится.
        for (const [i, s] of (area.lighting?.sources ?? []).entries()) {
            const lw = `${where}/light#${i}`;
            if (s.whenFlag !== undefined) checkFlag(s.whenFlag, lw, 'whenFlag');
            if (s.notFlag !== undefined) checkFlag(s.notFlag, lw, 'notFlag');
        }
        // Низины: полотно без реестра предметов = маска никогда не сработает.
        for (const [i, h] of (area.hazards ?? []).entries()) {
            if (!(h.requiresItem in ITEMS)) {
                out.push({ id: 'item-unknown', severity: 'error', message: `накат#${i} «${h.name}»: requiresItem «${h.requiresItem}» вне реестра ITEMS`, where });
            }
        }
        // Интерьеры (atmosphere 'none') выходят только назад: ровно один return-переход.
        if (area.atmosphere === 'none') {
            const returns = area.transitions.filter((t) => t.target.kind === 'return').length;
            if (returns !== 1) {
                out.push({ id: 'interior-return', severity: 'error', message: `интерьер должен иметь ровно один kind:'return' переход (сейчас ${returns})`, where });
            }
        }
        for (const obj of area.interactables ?? []) {
            const w = `${where}/interactable/${obj.id}`;
            for (const r of obj.responses) {
                if (r.when?.flag !== undefined) checkFlag(r.when.flag, w, 'when.flag');
                if (r.when?.notFlag !== undefined) checkFlag(r.when.notFlag, w, 'when.notFlag');
                for (const f of r.setFlags ?? []) checkFlag(f, w, 'setFlags');
                for (const f of r.clearFlags ?? []) checkFlag(f, w, 'clearFlags');
                if (r.setVar) checkVar(r.setVar.id, w, 'setVar.id');
                if (r.addVar) checkVar(r.addVar.id, w, 'addVar.id');
            }
        }
    }
    for (const quest of QUESTS) {
        const where = `${WHERE}/quest/${quest.id}`;
        for (const [i, stage] of quest.stages.entries()) {
            if (stage.doneFlag !== undefined) checkFlag(stage.doneFlag, where, `стадия#${i}.doneFlag`);
            if (stage.progressKey !== undefined) checkVar(stage.progressKey, where, `стадия#${i}.progressKey`);
            if (!NPCS.some((n) => n.id === stage.npc)) {
                out.push({ id: 'npc-ref', severity: 'error', message: `стадия#${i}: NPC «${stage.npc}» не существует`, where });
            }
            if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) {
                out.push({ id: 'dialogue-ref', severity: 'error', message: `стадия#${i}: диалог «${stage.dialogue}» не существует`, where });
                continue;
            }
            // Стадия с doneFlag должна выставляться setFlags в графе стадии —
            // иначе диалог никогда её не завершит.
            if (stage.doneFlag !== undefined && stage.dialogue !== undefined) {
                const graph = DIALOGUES[stage.dialogue];
                const sets = new Set<string>();
                for (const node of Object.values(graph.nodes)) {
                    for (const n of [node, ...(node.choices ?? [])]) {
                        for (const f of n.setFlags ?? []) sets.add(f);
                    }
                }
                if (!sets.has(stage.doneFlag)) {
                    out.push({ id: 'quest-stage-unreachable', severity: 'error', message: `стадия#${i}: doneFlag «${stage.doneFlag}» не выставляется в графе «${stage.dialogue}»`, where });
                }
            }
        }
    }
    for (const npc of NPCS) {
        checkFlag(npc.flagKey, `${WHERE}/npc/${npc.id}`, 'flagKey');
        if (!(npc.dialogueFirst in DIALOGUES)) {
            out.push({ id: 'dialogue-ref', severity: 'error', message: `NPC ${npc.id}: dialogueFirst «${npc.dialogueFirst}» не существует`, where: `${WHERE}/npc/${npc.id}` });
        }
        if (!(npc.dialogueRepeat in DIALOGUES)) {
            out.push({ id: 'dialogue-ref', severity: 'error', message: `npc ${npc.id}: dialogueRepeat «${npc.dialogueRepeat}» не существует`, where: `${WHERE}/npc/${npc.id}` });
        }
    }

    // Мёртвые сущности реестра: ключ не упомянут нигде в контенте (warn).
    for (const flag of Object.keys(FLAGS)) {
        if (!flagsUsed.has(flag)) {
            out.push({ id: 'flag-dead', severity: 'warn', message: `флаг «${flag}» из реестра не упоминается в контенте`, where: WHERE });
        }
    }
    for (const id of Object.keys(VARS)) {
        if (!varsUsed.has(id)) {
            out.push({ id: 'var-dead', severity: 'warn', message: `вар «${id}» из реестра не упоминается в контенте`, where: WHERE });
        }
    }
    return out;
}

/**
 * Аудио-ключи контента: опечатка в ключе = тишина в рантайме (никто не упадёт).
 * Лупы/темы/слои — по реестрам audio.ts и music.ts; sfx реакций и реплик — по
 * спек-реестру; roomtone/* — ключ обязан быть id существующей области.
 */
export function validateAudio(): Invariant[] {
    const out: Invariant[] = [];
    const sfx = new Set(Object.keys(SPEC_SFX));
    for (const area of Object.values(AREAS)) {
        const where = `${WHERE}/area/${area.id}`;
        if (area.ambience !== undefined && !AMBIENCE_KEYS.includes(area.ambience as never)) {
            out.push({ id: 'audio-key', severity: 'error', message: `ambience «${area.ambience}» вне AMBIENCE_KEYS`, where });
        }
        if (area.theme !== undefined && !(area.theme in THEMES)) {
            out.push({ id: 'audio-key', severity: 'error', message: `theme «${area.theme}» вне THEMES`, where });
        }
        (area.ambienceLayers ?? []).forEach((layer, i) => {
            if (!AMBIENCE_LAYER_KEYS.includes(layer.key as never)) {
                out.push({ id: 'audio-key', severity: 'error', message: `ambienceLayer#${i} «${layer.key}» вне AMBIENCE_LAYER_KEYS`, where });
            }
        });
        for (const obj of area.interactables ?? []) {
            for (const [i, r] of obj.responses.entries()) {
                if (r.sound !== undefined && !sfx.has(r.sound)) {
                    out.push({ id: 'audio-key', severity: 'error', message: `${obj.id} response#${i}: sfx «${r.sound}» вне спек-реестра`, where: `${where}/${obj.id}` });
                }
            }
        }
    }
    for (const [id, graph] of Object.entries(DIALOGUES)) {
        for (const [nodeId, node] of Object.entries(graph.nodes)) {
            for (const n of [node, ...(node.choices ?? [])]) {
                for (const op of n.do ?? []) {
                    if (op.kind === 'sound' && op.id !== undefined && !sfx.has(op.id)) {
                        out.push({ id: 'audio-key', severity: 'error', message: `узел «${nodeId}»: sfx «${op.id}» вне спек-реестра`, where: `${WHERE}/dialogue/${id}` });
                    }
                }
            }
        }
    }
    for (const key of Object.keys(ROOM_TONES)) {
        if (!(key in AREAS)) {
            out.push({ id: 'audio-key', severity: 'error', message: `roomtone «${key}»: области не существует`, where: `${WHERE}/roomtone` });
        }
    }
    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(),
        validateAudio(),
        validateReferences()
    );
}