import {
findPath,
checkFinite,
checkRange,
checkWalkable,
mergeInvariants,
tileToWorld,
type Invariant,
type IsometricTileMap,
type JsonValue,
type SceneAgent,
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;
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,
d.combat.agentInvariants()
);
}
/** 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;
}
}
}