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;
}
}
}