diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index bb0974b..bd178ef 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -9,7 +9,14 @@ type SnapshotLayer } from '@rpg/engine'; import type { Game } from '../Game'; -import { AGENT_SNAPSHOT_KEYS, gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot'; +import { + AGENT_SNAPSHOT_KEYS, + gameLayer, + type DialogueSnapshot, + type GameSnapshot, + type HeroSnapshot, + type InteractableSnapshot +} from './snapshot'; import { validateContent } from '../data/validate'; import { usedFlag } from '../data/ids'; @@ -108,7 +115,7 @@ // used-флаги и снапшот идут разными путями — сверяем их на месте. const s = this.snapshot(); const flags = new Set(s.flags ?? []); - for (const o of (s.interactables ?? []) as { id: string; used: boolean }[]) { + for (const o of (s.interactables ?? []) as InteractableSnapshot[]) { if (flags.has(usedFlag(o.id)) !== o.used) { list.push({ id: 'interact-used-consistent', @@ -151,7 +158,7 @@ const s = this.snapshot(); let ok = false; try { - ok = fn(s as unknown as SnapshotLayer); + ok = fn(s); } catch { // предикат упал — трактуем как «не совпало» } @@ -203,13 +210,13 @@ { timeoutTicks: opts?.timeoutTicks ?? 240 } ); if (!wait.ok) { - const hero = this.snapshot().hero as { tile?: { x: number; y: number }; moving?: boolean } | undefined; + const hero = this.snapshot().hero as HeroSnapshot | undefined; // Герой ещё идёт или что-то пошло не так — не угадываем. if (!hero?.tile || hero.moving || ++misses > 4) return false; continue; } } - const hero = this.snapshot().hero as { tile?: { x: number; y: number } } | undefined; + const hero = this.snapshot().hero as HeroSnapshot | undefined; return hero?.tile?.x === tx && hero?.tile?.y === ty; } diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index b8b9042..8ea3483 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -266,18 +266,18 @@ 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[], + hero: heroLayer(hero).hero, + enemies: enemiesLayer(enemies).enemies, + npcs: npcsLayer(npcs).npcs, 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'], + collision: collisionLayer(d.map.data).collision, + lighting: lightingLayer(d.lighting().snapshot()).lighting, + dialogue: dialogueLayer(d.dialogue.agentState).dialogue, cutscene: { active: d.cutscene.active }, lastToast: d.lastToast() }; - return layer as unknown as SnapshotLayer; + return layer; } /** Инварианты сцены: валидность контента + целостность героя/врагов. */ diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 3ed7a48..97d4af1 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -96,7 +96,7 @@ it('NPC: копия с met, без ссылок на источник', () => { const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false }; const layer = npcsLayer([n]); - const out = (layer.npcs as unknown as NpcSnapshot[])[0]!; + const out = layer.npcs[0]!; expect(out).toEqual(n); expect(out).not.toBe(n); }); @@ -173,7 +173,7 @@ vars: {}, inventory: [], time: { minutes: 480, label: '8:00', night: 0 } - }) as GameSnapshot; + }); const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]); expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]); }); diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index 914795b..a98b1cc 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -1,4 +1,4 @@ -import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type JsonValue, type TileMapData } from '@rpg/engine'; +import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type TileMapData } from '@rpg/engine'; /** * Контентный слой снапшота агентного моста. Чистые функции — тестируются @@ -29,8 +29,14 @@ 'time' ]; +/* + * Все снапшот-типы ниже — type alias'ы, не interface: у alias'ов есть неявная + * индексная подпись, поэтому они присваиваются JsonValue/SnapshotLayer без + * кастов (interface — нет). Рассинхрон слоя и типа — ошибка компиляции. + */ + /** Герой. */ -export interface HeroSnapshot { +export type HeroSnapshot = { tile: { x: number; y: number }; pos: { x: number; y: number }; hp: number; @@ -39,28 +45,28 @@ moving: boolean; invuln: boolean; inHazard: string | null; -} +}; /** Враг. */ -export interface EnemySnapshot { +export type EnemySnapshot = { kind: string; state: string | null; hp: number; pos: { x: number; y: number }; asleep: boolean; dead: boolean; -} +}; /** NPC. */ -export interface NpcSnapshot { +export type NpcSnapshot = { id: string; name: string; tile: { x: number; y: number }; met: boolean; -} +}; /** Активный диалог (или null). */ -export interface DialogueSnapshot { +export type DialogueSnapshot = { id: string | null; nodeId: string | null; speaker: string | null; @@ -76,7 +82,7 @@ } /** Переход (дверь/выход/портал) — из данных локации. */ -export interface TransitionSnapshot { +export type TransitionSnapshot = { tile: { x: number; y: number }; to: string; /** Как срабатывает: шагом на тайл или кликом. */ @@ -85,7 +91,7 @@ } /** Интерактивный объект области (used — для once-объектов). */ -export interface InteractableSnapshot { +export type InteractableSnapshot = { id: string; tile: { x: number; y: number }; label: string | null; @@ -93,7 +99,7 @@ } /** Источник света (пост-мерцание). */ -export interface LightSourceSnapshot { +export type LightSourceSnapshot = { id: string; x: number; y: number; @@ -102,7 +108,7 @@ } /** Освещение сцены: ambient (multiply-цвет) + виньетка + активные источники. */ -export interface LightingSnapshot { +export type LightingSnapshot = { ambient: number; /** Целевая интенсивность виньетки 0..1. */ vignette: number; @@ -110,7 +116,7 @@ } /** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */ -export interface CollisionSnapshot { +export type CollisionSnapshot = { width: number; height: number; /** Проходимость по тайлам, построчно: 0 — свободен, 1 — блок (включая footprint пропов). */ @@ -119,8 +125,8 @@ props: { x: number; y: number; w: number; h: number }[]; } -/** Контентный слой сцены локации. */ -export interface LocationSnapshot { +/** Контентный слой сцены локации (структурно = SnapshotLayer). */ +export type LocationSnapshot = { scene: 'location'; area: string; areaName: string; @@ -139,7 +145,7 @@ } /** Время суток (ход игры — непрерывный, 1 с реального = 1 игровая минута). */ -export interface TimeSnapshot { +export type TimeSnapshot = { /** Целые минуты суток 0..1439. */ minutes: number; /** Строка «8:24» (как на часах предмета). */ @@ -148,9 +154,8 @@ night: number; } -/** Полный снапшот игры: движковый слой + слои игры. */ -export interface GameSnapshot { - [k: string]: JsonValue | unknown; +/** Полный снапшот игры: движковый слой + сценический + слой игры. */ +export type GameSnapshot = SnapshotLayer & { /** Флаги прохождения. */ flags: string[]; /** Переменные прохождения. */ @@ -159,7 +164,7 @@ inventory: { id: string; count: number }[]; /** Время суток. */ time: TimeSnapshot; -} +}; /** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */ function r(v: number): number { @@ -167,7 +172,7 @@ } /** Слой карты коллизий: blocked по тайлам (gridOf учитывает footprint пропов). */ -export function collisionLayer(data: TileMapData): SnapshotLayer { +export function collisionLayer(data: TileMapData): SnapshotLayer & { collision: CollisionSnapshot } { const grid = gridOf(data); const blocked: number[] = []; for (let y = 0; y < data.height; y++) { @@ -181,11 +186,11 @@ blocked, props: (data.props ?? []).map((p) => ({ x: p.x, y: p.y, w: p.w ?? 1, h: p.h ?? 1 })) }; - return { collision: collision as unknown as JsonValue }; + return { collision }; } /** Слой героя. */ -export function heroLayer(o: HeroSnapshot): SnapshotLayer { +export function heroLayer(o: HeroSnapshot): SnapshotLayer & { hero: HeroSnapshot } { return { hero: { tile: { x: o.tile.x, y: o.tile.y }, @@ -201,7 +206,7 @@ } /** Слой врагов. */ -export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer { +export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer & { enemies: EnemySnapshot[] } { return { enemies: enemies.map((e) => ({ kind: e.kind, @@ -210,29 +215,29 @@ pos: { x: r(e.pos.x), y: r(e.pos.y) }, asleep: e.asleep, dead: e.dead - })) as unknown as JsonValue[] + })) }; } /** Слой NPC. */ -export function npcsLayer(npcs: NpcSnapshot[]): SnapshotLayer { +export function npcsLayer(npcs: NpcSnapshot[]): SnapshotLayer & { npcs: NpcSnapshot[] } { return { npcs: npcs.map((n) => ({ id: n.id, name: n.name, tile: { x: n.tile.x, y: n.tile.y }, met: n.met - })) as unknown as JsonValue[] + })) }; } /** Слой диалога. */ -export function dialogueLayer(d: DialogueSnapshot | null): SnapshotLayer { - return { dialogue: d ? (d as unknown as JsonValue) : null }; +export function dialogueLayer(d: DialogueSnapshot | null): SnapshotLayer & { dialogue: DialogueSnapshot | null } { + return { dialogue: d ? { ...d } : null }; } /** Слой освещения: ambient + виньетка + активные источники (координаты экранные px). */ -export function lightingLayer(o: LightingSnapshot): SnapshotLayer { +export function lightingLayer(o: LightingSnapshot): SnapshotLayer & { lighting: LightingSnapshot } { return { lighting: { ambient: o.ambient, @@ -244,7 +249,7 @@ color: s.color, intensity: r(s.intensity) })) - } as unknown as JsonValue + } }; }