Newer
Older
rpg / apps / game / src / agent / SceneAgentView.ts
import {
    dayNightFactor,
    findPath,
    checkFinite,
    checkRange,
    checkWalkable,
    mergeInvariants,
    tileToWorld,
    type Invariant,
    type Grid,
    type IsometricTileMap,
    type JsonValue,
    type SceneAgent,
    type SceneRegistry,
    type SnapshotLayer,
    type SoundKind,
    type SoundSpec,
    type Vec2
} 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-команд моста (все опциональны; типы проверяют обработчики). */
interface CommandArgs {
    x?: number;
    y?: number;
    level?: number;
    id?: string;
    value?: number | string | boolean;
    flag?: string;
    index?: number;
    hours?: number;
    text?: string;
    from?: { x?: number; y?: number };
    to?: { x?: number; y?: number };
    key?: string;
    volume?: number;
    spec?: Partial<SoundSpec>;
}

/** Обработчик команды моста: deps сцены + разобранные аргументы. */
type CommandHandler = (d: SceneAgentDeps, a: CommandArgs) => JsonValue;

/** Аргументы-тайлы x/y заданы числами (общая проверка тайловых команд). */
function hasXY(a: CommandArgs): a is CommandArgs & { x: number; y: number } {
    return typeof a.x === 'number' && typeof a.y === 'number';
}

/** Таблица whitelist-команд (name → обработчик); неизвестное имя — null в agentCommand. */
const COMMANDS: Record<string, CommandHandler> = {
    'scene:sleepAll': (d) => {
        for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999);
        return true;
    },
    'scene:noise': (d, a) => {
        // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих.
        if (!hasXY(a)) return null;
        d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1);
        return true;
    },
    'scene:damageEnemy': (d, a) => {
        // Урон живому сгустку (проверки отступления); 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;
    },
    'scene:damagePlayer': (d, a) => {
        // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged):
        // вспышка, шейк, виньетка при низком hp.
        if (typeof a.value !== 'number') return null;
        d.damagePlayer(a.value);
        return true;
    },
    'scene:give': (d, a) => {
        if (typeof a.id !== 'string') return null;
        d.game.inventory.add(a.id);
        return true;
    },
    'scene:setVar': (d, a) => {
        if (typeof a.id !== 'string') return null;
        d.game.state.setVar(a.id, a.value ?? 0);
        return true;
    },
    'scene:setFlag': (d, a) => {
        if (typeof a.flag !== 'string') return null;
        d.game.state.setFlag(a.flag);
        return true;
    },
    'scene:setTime': (d, a) => {
        // Перемотка времени суток (часы → минуты) — детерминизм проверок света.
        if (typeof a.hours !== 'number') return null;
        d.game.clock.set(a.hours * 60);
        return true;
    },
    'scene:teleport': (d, a) => {
        if (!hasXY(a)) return null;
        d.player.teleportTo({ x: a.x, y: a.y });
        d.followCamera(true);
        return true;
    },
    'scene:route': (d, a) => {
        if (!hasXY(a)) return null;
        // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело.
        return findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false) ?? null;
    },
    'scene:walkable': (d, a) => {
        // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют).
        if (!hasXY(a)) return null;
        return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y));
    },
    'scene:raycast': (d, a) => {
        // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке.
        const f = a.from;
        const t = a.to;
        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);
    },
    'scene:pickChoice': (d, a) => {
        if (typeof a.index !== 'number') return null;
        d.dialogue.pickChoice(a.index);
        return true;
    },
    'scene:pickChoiceByText': (d, a) => {
        // Выбор по тексту реплики — сценариям не надо знать порядок вариантов.
        if (typeof a.text !== 'string') return null;
        return d.dialogue.pickChoiceByText(a.text);
    },
    'scene:skipCutscene': (d) => {
        if (!d.cutscene.active) return false;
        while (d.cutscene.active) d.cutscene.update(0.5);
        return true;
    },
    'scene:synthesize': (d, a) => {
        // Звук по описанию (спек → playSpec): агент без слуха описывает
        // звук параметрами; факт запуска виден в DEV-логе аудио.
        const s = a.spec;
        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;
    }
};

/**
 * Агентный фасад сцены локации: слой снапшота, инварианты, 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;
    /** Урон герою полным боевым путём (как у сгустков — for scene:damagePlayer). */
    damagePlayer(damage: number, from?: Vec2): void;
    /** Игровая обвязка освещения (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),
            this.timeInvariant(where),
            d.combat.agentInvariants()
        );
    }

    /** Время суток в допустимых пределах: конечные минуты 0..1439, night 0..1, label «H:MM». */
    private timeInvariant(where: string): Invariant[] {
        const bad: Invariant = {
            id: 'time-bounded',
            severity: 'error',
            message: 'время суток вне допустимых пределов',
            where
        };
        const t = {
            minutes: this.deps.game.clock.minutes,
            label: this.deps.game.clock.label,
            night: dayNightFactor(this.deps.game.clock.hours)
        };
        if (!Number.isFinite(t.minutes) || t.minutes < 0 || t.minutes >= 1440) return [bad];
        if (!Number.isFinite(t.night) || t.night < 0 || t.night > 1) return [bad];
        if (!/^\d{1,2}:\d{2}$/.test(t.label)) return [bad];
        return [];
    }

    /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, виньетка 0..1, источников ≤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];
        if (!Number.isFinite(l.vignette) || l.vignette < 0 || l.vignette > 1) 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 handler = COMMANDS[name];
        if (!handler) return null;
        return handler(this.deps, (args ?? {}) as CommandArgs);
    }
}