Newer
Older
rpg / apps / game / src / agent / SceneAgentView.ts
import {
    findPath,
    checkFinite,
    checkRange,
    checkWalkable,
    mergeInvariants,
    tileToWorld,
    type Invariant,
    type IsometricTileMap,
    type JsonValue,
    type SceneAgent,
    type SceneRegistry,
    type SnapshotLayer
} from '@rpg/engine';
import type { CutsceneRunner } from '@rpg/engine';
import type { Game } from '../Game';
import type { AreaDef, HazardDef } from '../data/locations';
import type { NpcDef } from '../data/npcs';
import { PLAYER_COMBAT } from '../systems/combat/stats';
import type { CombatWorld } from '../systems/combat/CombatWorld';
import type { PlayerCombat } from '../systems/combat/PlayerCombat';
import type { PlayerController } from '../systems/PlayerController';
import type { Interactables } from '../systems/Interactables';
import type { DialogueSystem } from '../systems/DialogueSystem';
import type {
    HeroSnapshot,
    EnemySnapshot,
    NpcSnapshot,
    TransitionSnapshot,
    LocationSnapshot,
    InteractableSnapshot
} from './snapshot';
import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer } from './snapshot';

/**
 * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды.
 * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором
 * вьюх, а мосту хватало узкого deps-объекта. Слои снапшота собираются теми же
 * фабриками, что тестируются в snapshot.test.ts, — расхождение невозможно.
 */
export interface SceneAgentDeps {
    game: Game;
    map: IsometricTileMap;
    area: AreaDef;
    player: PlayerController;
    playerCombat: PlayerCombat;
    combat: CombatWorld;
    /** Определения NPC области (вьюхи не нужны мосту). */
    npcs: readonly NpcDef[];
    interactables: Interactables;
    /** Реестр объектов сцены (для инварианта согласованности). */
    registry: SceneRegistry;
    dialogue: DialogueSystem;
    cutscene: CutsceneRunner;
    lastToast(): { text: string; tick: number } | null;
    inHazard(): HazardDef | null;
    /** Камера в ногах героя (snap — телепорты). */
    followCamera(snap: boolean): void;
}

export class SceneAgentView implements SceneAgent {
    constructor(private deps: SceneAgentDeps) {}

    /** Контентный слой снапшота — см. snapshot.ts. */
    agentSnapshot(): SnapshotLayer {
        const d = this.deps;
        const hero: HeroSnapshot = {
            tile: d.player.currentTile(),
            pos: d.player.position,
            hp: d.playerCombat.hp,
            maxHp: PLAYER_COMBAT.maxHp,
            facing: d.player.dir,
            moving: d.player.moving,
            invuln: d.playerCombat.invuln,
            inHazard: d.inHazard()?.name ?? null
        };
        const enemies: EnemySnapshot[] = [];
        for (const [, en] of d.combat.enemies) {
            enemies.push({
                kind: en.kind.id,
                state: en.brain.state,
                hp: en.hp,
                pos: en.pos,
                asleep: en.brain.asleep,
                dead: en.brain.dead
            });
        }
        const npcs: NpcSnapshot[] = d.npcs.map((def) => ({
            id: def.id,
            name: def.name,
            tile: def.tile,
            met: d.game.state.hasFlag(def.flagKey)
        }));
        const transitions: TransitionSnapshot[] = d.area.transitions.map((t) => ({
            tile: t.tile,
            to: t.target.kind === 'area' ? t.target.area : '<return>',
            trigger: t.trigger ?? 'step',
            label: t.label ?? null
        }));
        const interactables: InteractableSnapshot[] = (d.area.interactables ?? []).map((def) => ({
            id: def.id,
            tile: def.tile,
            label: def.label ?? null,
            used: d.interactables.isUsed(def.id)
        }));
        const layer: LocationSnapshot = {
            scene: 'location',
            area: d.area.id,
            areaName: d.area.name,
            hero: heroLayer(hero).hero as unknown as HeroSnapshot,
            enemies: enemiesLayer(enemies).enemies as unknown as EnemySnapshot[],
            npcs: npcsLayer(npcs).npcs as unknown as NpcSnapshot[],
            transitions,
            interactables,
            dialogue: dialogueLayer(d.dialogue.agentState).dialogue as LocationSnapshot['dialogue'],
            cutscene: { active: d.cutscene.active },
            lastToast: d.lastToast()
        };
        return layer as unknown as SnapshotLayer;
    }

    /** Инварианты сцены: валидность контента + целостность героя/врагов. */
    agentInvariants(): Invariant[] {
        const where = 'scene/LocationScene';
        const d = this.deps;
        const heroTile = d.player.currentTile();
        const heroPos = d.player.position;
        const enemyPos: Record<string, number> = {};
        const enemyChecks: Invariant[] = [];
        for (const [e, en] of d.combat.enemies) {
            enemyPos[`enemy#${e}.x`] = en.pos.x;
            enemyPos[`enemy#${e}.y`] = en.pos.y;
            if (!en.brain.dead && !d.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) {
                enemyChecks.push({
                    id: 'enemy-in-wall',
                    severity: 'error',
                    message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`,
                    where
                });
            }
        }
        return mergeInvariants(
            checkFinite(
                { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos },
                where
            ),
            checkRange('hero.hp', d.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where),
            checkWalkable('герой', heroTile, d.map, where),
            enemyChecks,
            this.registryInvariant(where),
            d.combat.agentInvariants()
        );
    }

    /** Реестр согласован с ECS: у каждого живого врага есть запись, позиции совпадают. */
    private registryInvariant(where: string): Invariant[] {
        const out: Invariant[] = [];
        for (const [e, en] of this.deps.combat.enemies) {
            if (en.brain.dead) continue;
            const obj = this.deps.registry.get(`enemy:${e}`);
            if (!obj) {
                out.push({
                    id: 'registry-consistent',
                    severity: 'error',
                    message: `враг #${e} (${en.kind.id}) нет в реестре сцены`,
                    where
                });
            } else if (obj.pos.x !== en.pos.x || obj.pos.y !== en.pos.y) {
                out.push({
                    id: 'registry-consistent',
                    severity: 'error',
                    message: `враг #${e} (${en.kind.id}): позиция реестра разошлась с ECS`,
                    where
                });
            }
        }
        return out;
    }

    /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */
    agentCommand(name: string, args?: JsonValue): JsonValue {
        const d = this.deps;
        const a = (args ?? {}) as { x?: number; y?: number; level?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number };
        switch (name) {
            case 'scene:sleepAll':
                for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999);
                return true;
            case 'scene:noise': {
                // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих.
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1);
                return true;
            }
            case 'scene:damageEnemy': {
                // Урон живому сгустку (проверки отступления); id — фильтр по виду.
                if (typeof a.value !== 'number') return null;
                for (const [e, en] of d.combat.enemies) {
                    if (en.brain.dead) continue;
                    if (typeof a.id === 'string' && en.kind.id !== a.id) continue;
                    d.combat.damageEnemy(e, en, a.value, d.player.position);
                    return true;
                }
                return false;
            }
            case 'scene:give':
                if (typeof a.id !== 'string') return null;
                d.game.inventory.add(a.id);
                return true;
            case 'scene:setVar':
                if (typeof a.id !== 'string') return null;
                d.game.state.setVar(a.id, a.value ?? 0);
                return true;
            case 'scene:setFlag':
                if (typeof a.flag !== 'string') return null;
                d.game.state.setFlag(a.flag);
                return true;
            case 'scene:teleport': {
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                d.player.teleportTo({ x: a.x, y: a.y });
                d.followCamera(true);
                return true;
            }
            case 'scene:route': {
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                const path = findPath(d.map, d.player.currentTile(), { x: a.x, y: a.y }, false);
                return path ?? null;
            }
            case 'scene:pickChoice':
                if (typeof a.index !== 'number') return null;
                d.dialogue.pickChoice(a.index);
                return true;
            case 'scene:skipCutscene': {
                if (!d.cutscene.active) return false;
                while (d.cutscene.active) d.cutscene.update(0.5);
                return true;
            }
            default:
                return null;
        }
    }
}