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