Newer
Older
rpg / apps / game / src / agent / SceneAgentView.ts
import {
    findPath,
    checkFinite,
    checkRange,
    checkWalkable,
    mergeInvariants,
    tileToWorld,
    type Invariant,
    type Grid,
    type IsometricTileMap,
    type JsonValue,
    type SceneAgent,
    type SceneRegistry,
    type SnapshotLayer,
    type SoundKind,
    type SoundSpec
} 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, collisionLayer, lightingLayer } from './snapshot';
import type { GameLighting } from '../systems/Lighting';
import { MAX_LIGHTS } from '../systems/Lighting';
import { hasLineOfSight } from '../systems/combat/los';

/** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */
const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum', 'tone'];

/**
 * Агентный фасад сцены локации: слой снапшота, инварианты, 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;
    /** Grid путей с занятыми NPC-тайлами (для scene:route — как у героя). */
    walkGrid(): Grid;
    dialogue: DialogueSystem;
    cutscene: CutsceneRunner;
    lastToast(): { text: string; tick: number } | null;
    inHazard(): HazardDef | null;
    /** Игровая обвязка освещения (ambient + источники для снапшота). */
    lighting(): GameLighting;
    /** Камера в ногах героя (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,
            collision: collisionLayer(d.map.data).collision as unknown as LocationSnapshot['collision'],
            lighting: lightingLayer(d.lighting().snapshot()).lighting as unknown as LocationSnapshot['lighting'],
            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),
            this.lightingInvariant(where),
            d.combat.agentInvariants()
        );
    }

    /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, источников ≤24. */
    private lightingInvariant(where: string): Invariant[] {
        const bad: Invariant = {
            id: 'lighting-bounded',
            severity: 'error',
            message: 'источники света вне допустимых пределов',
            where
        };
        const l = this.deps.lighting().snapshot();
        if (l.sources.length > MAX_LIGHTS || !Number.isFinite(l.ambient)) return [bad];
        for (const s of l.sources) {
            if (!Number.isFinite(s.x) || !Number.isFinite(s.y) || s.intensity < 0 || s.intensity > 2) return [bad];
        }
        return [];
    }

    /** Реестр согласован с 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;
            text?: string;
            from?: { x?: number; y?: number };
            to?: { x?: number; y?: number };
            key?: string;
            volume?: number;
            spec?: Partial<SoundSpec>;
        };
        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;
                // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело.
                const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false);
                return path ?? null;
            }
            case 'scene:walkable': {
                // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют).
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y));
            }
            case 'scene:raycast': {
                // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке.
                const f = a.from as { x?: number; y?: number } | undefined;
                const t = a.to as { x?: number; y?: number } | undefined;
                if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') {
                    return null;
                }
                return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque);
            }
            case 'scene:pickChoice':
                if (typeof a.index !== 'number') return null;
                d.dialogue.pickChoice(a.index);
                return true;
            case 'scene:pickChoiceByText':
                // Выбор по тексту реплики — сценариям не надо знать порядок вариантов.
                if (typeof a.text !== 'string') return null;
                return d.dialogue.pickChoiceByText(a.text);
            case 'scene:skipCutscene': {
                if (!d.cutscene.active) return false;
                while (d.cutscene.active) d.cutscene.update(0.5);
                return true;
            }
            case 'scene:synthesize': {
                // Звук по описанию (спек → playSpec): агент без слуха описывает
                // звук параметрами; факт запуска виден в DEV-логе аудио.
                const s = a.spec as Partial<SoundSpec> | undefined;
                if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') {
                    return null;
                }
                const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur };
                if (typeof s.low === 'number') spec.low = s.low;
                if (typeof s.high === 'number') spec.high = s.high;
                if (typeof s.tone === 'number') spec.tone = s.tone;
                if (typeof s.freq === 'number') spec.freq = s.freq;
                if (typeof s.power === 'number') spec.power = s.power;
                if (typeof s.seed === 'number') spec.seed = s.seed;
                if (typeof s.peak === 'number') spec.peak = s.peak;
                if (typeof s.loop === 'boolean') spec.loop = s.loop;
                void d.game.audio.playSpec(
                    typeof a.key === 'string' ? a.key : 'agent/synth',
                    spec,
                    typeof a.volume === 'number' ? a.volume : 0.6
                );
                return true;
            }
            default:
                return null;
        }
    }
}