diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 568a79b..4cd2f7e 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -8,7 +8,7 @@ type SnapshotLayer } from '@rpg/engine'; import type { Game } from '../Game'; -import { gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot'; +import { AGENT_SNAPSHOT_KEYS, gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot'; import { validateContent } from '../data/validate'; import { usedFlag } from '../data/ids'; @@ -29,7 +29,7 @@ waitFor( pred: string, opts?: { timeoutTicks?: number; render?: boolean } - ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }>; + ): Promise; /** Клик по тайлу (в юнитах; не зависит от видимости тайла на канвасе). */ tapTile(tx: number, ty: number): void; /** Клик в виртуальных пикселях (480×270). */ @@ -55,6 +55,24 @@ /** Действие «листать диалог» (маппится и на Space, и на Enter). */ const ADVANCE = 'advance'; +/** Итог waitFor: error заполняется только при отклонённом предикате. */ +export interface WaitResult { + ok: boolean; + snapshot: GameSnapshot; + ticks: number; + /** Предикат ссылался на неизвестный ключ (опечатка) — исполнение не начато. */ + error?: string; +} + +/** Top-level обращения `s.<ключ>` в строке-предикате (вложенные пути не трогаем). */ +export function predKeys(pred: string): string[] { + const re = /(?:^|[^.\w$])s\.([A-Za-z_$][\w$]*)/g; + const keys = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(pred)) !== null) keys.add(m[1]!); + return [...keys]; +} + export class GameAgent { private engineAgent: EngineAgent; @@ -102,7 +120,18 @@ async waitFor( pred: string, opts?: { timeoutTicks?: number; render?: boolean } - ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }> { + ): Promise { + // Опечатка в ключе предиката — тихий false на весь таймаут. Отклоняем + // сразу: unknown key -> { ok: false, error }, шаги не тратятся. + const unknown = predKeys(pred).filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k)); + if (unknown.length > 0) { + return { + ok: false, + snapshot: this.snapshot(), + ticks: 0, + error: `неизвестный ключ s.${unknown[0]} в предикате (см. AGENT_SNAPSHOT_KEYS)` + }; + } // Строка-выражение приходит из apps/game/tools/ (доверенная среда репозитория). const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; const limit = opts?.timeoutTicks ?? 600; diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 21a6346..4c934bb 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from 'vitest'; import { + AGENT_SNAPSHOT_KEYS, dialogueLayer, enemiesLayer, gameLayer, heroLayer, npcsLayer, type EnemySnapshot, + type GameSnapshot, + type LocationSnapshot, type NpcSnapshot } from '../snapshot'; +import { predKeys } from '../GameAgent'; describe('снапшот агента — слои', () => { it('герой: позиция округляется до 3 знаков, остальное копируется', () => { @@ -73,4 +77,77 @@ expect(layer.vars).toEqual({ flowers: 2 }); expect(layer.inventory[0]!.count).toBe(2); }); +}); + +describe('AGENT_SNAPSHOT_KEYS — анти-дрейф', () => { + const locationSample: LocationSnapshot = { + scene: 'location', + area: 'meadows', + areaName: 'Выжженные луга', + hero: { + tile: { x: 0, y: 0 }, + pos: { x: 0, y: 0 }, + hp: 10, + maxHp: 10, + facing: 'down', + moving: false, + invuln: false, + inHazard: null + }, + enemies: [], + npcs: [], + transitions: [], + interactables: [], + dialogue: null, + cutscene: null, + lastToast: null + }; + + it('все top-level ключи сцены и слоя игры есть в списке', () => { + const gamePart: GameSnapshot = gameLayer({ + flags: [], + vars: {}, + inventory: [] + }) as GameSnapshot; + const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]); + expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]); + }); + + it('движковые ключи (слоя EngineAgent) не утеряны из списка', () => { + for (const k of ['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer']) { + expect(AGENT_SNAPSHOT_KEYS).toContain(k); + } + }); + + it('в списке нет лишних (список = сцена + игра + движок)', () => { + const locationKeys = Object.keys(locationSample); + const gameKeys = ['flags', 'vars', 'inventory']; + const extra = AGENT_SNAPSHOT_KEYS.filter( + (k) => !locationKeys.includes(k) && !gameKeys.includes(k) && !['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer'].includes(k) + ); + expect(extra).toEqual([]); + }); +}); + +describe('predKeys — top-level ключи предиката', () => { + it('одиночное обращение и вложенный путь', () => { + expect(predKeys('s.hero')).toEqual(['hero']); + expect(predKeys('s.hero && s.hero.tile.x === 1')).toEqual(['hero']); + }); + + it('несколько ключей и сложные выражения', () => { + expect(predKeys('!s.transitioning && s.area === "zvenets"')).toEqual( + expect.arrayContaining(['transitioning', 'area']) + ); + expect(predKeys('s.vars.motes >= 1 || s.flags.includes("x")')).toEqual( + expect.arrayContaining(['vars', 'flags']) + ); + expect(predKeys('(s.dialogue != null)')).toEqual(['dialogue']); + }); + + it('не ловит не-обращения (строки, другие объекты)', () => { + expect(predKeys('s.scene === "location"')).toEqual(['scene']); + expect(predKeys('s.x === 1')).toEqual(['x']); // pointer-слой — легальный ключ движка + expect(predKeys('foo.s.bar === 1')).toEqual([]); // не наш s + }); }); \ No newline at end of file diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index b2907f8..c1f82ca 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -1,10 +1,31 @@ -import type { SnapshotLayer, JsonValue } from '@rpg/engine'; +import { ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type JsonValue } from '@rpg/engine'; /** * Контентный слой снапшота агентного моста. Чистые функции — тестируются * в Vitest без браузера; LocationScene/GameAgent подставляют живые данные. */ +/** Top-level ключи полного снапшота игры: движковый слой + сцена + слой игры. */ +export const AGENT_SNAPSHOT_KEYS: readonly string[] = [ + ...ENGINE_SNAPSHOT_KEYS, + // слой сцены локации (LocationSnapshot) + 'scene', + 'area', + 'areaName', + 'hero', + 'enemies', + 'npcs', + 'transitions', + 'interactables', + 'dialogue', + 'cutscene', + 'lastToast', + // слой игры (gameLayer) + 'flags', + 'vars', + 'inventory' +]; + /** Герой. */ export interface HeroSnapshot { tile: { x: number; y: number }; diff --git a/apps/game/src/systems/__tests__/Interactables.test.ts b/apps/game/src/systems/__tests__/Interactables.test.ts index df9ee67..e5d4753 100644 --- a/apps/game/src/systems/__tests__/Interactables.test.ts +++ b/apps/game/src/systems/__tests__/Interactables.test.ts @@ -1,21 +1,8 @@ import { describe, expect, it } from 'vitest'; import { Interactables, type InteractSink } from '../Interactables'; -import type { GameState } from '@rpg/engine'; +import { fakeState } from '../../testing/gameState'; import type { InteractableDef } from '../../data/interactables'; -/** Фейковый GameState: флаги и вар-числа в памяти. */ -function fakeState(): GameState { - const flags = new Set(); - const vars = new Map(); - return { - setFlag: (f: string) => void flags.add(f), - hasFlag: (f: string) => flags.has(f), - clearFlag: (f: string) => void flags.delete(f), - setVar: (k: string, v: number) => void vars.set(k, Number(v)), - getNumber: (k: string) => vars.get(k) ?? 0 - } as unknown as GameState; -} - const def = (over: Partial = {}): InteractableDef => ({ id: 'chest', tile: { x: 3, y: 3 }, diff --git a/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts b/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts index 1f2d9a8..d1cf05b 100644 --- a/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts +++ b/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts @@ -1,29 +1,13 @@ import { describe, expect, it } from 'vitest'; import { ENEMY_KINDS, withDefaults } from '../../../data/enemies'; import { EnemyBrain, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemySenses } from '../EnemyBrain'; -import { CombatWorld } from '../CombatWorld'; -import type { IsometricTileMap, AudioManager, EventBus } from '@rpg/engine'; +import { far, fakeWorld, run } from '../../../testing/combatWorld'; -/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ -const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType => { - let intent: ReturnType = { type: 'idle' }; - for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); - return intent; -}; - -/** Патрульные точки в юнитах и сенсоры «герой далеко и не виден». */ +/** Патрульные точки в юнитах. */ const points = [ { x: 10.5, y: 7.5 }, { x: 12.5, y: 9.5 } ]; -const far = (pos: { x: number; y: number }): EnemySenses => ({ - dist: 30, - dirToPlayer: { x: 1, y: 0 }, - pos, - canSee: false, - home: { x: 10.5, y: 7.5 }, - hpFraction: 1 -}); describe('EnemyBrain: патруль', () => { it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => { @@ -150,28 +134,6 @@ // ---------- CombatWorld: пороги шума ---------- -/** Фейковая карта: данные 20×20 травы, проходима всюду (Pixi не нужен). */ -function fakeMap(): IsometricTileMap { - const data = { - width: 20, - height: 20, - tiles: new Array(400).fill(0), - blocked: [], - tall: undefined - }; - return { data, isWalkable: () => true } as unknown as IsometricTileMap; -} - -function fakeWorld(playerPos: { x: number; y: number }): CombatWorld { - return new CombatWorld({ - map: fakeMap(), - events: { emit: () => {} } as unknown as EventBus, - audio: { play: () => Promise.resolve() } as unknown as AudioManager, - getPlayerPos: () => playerPos, - damagePlayer: () => {} - }); -} - describe('CombatWorld: шум будит только от гулкого', () => { it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => { const hero = { x: 20, y: 20 }; diff --git a/apps/game/src/systems/combat/__tests__/los.test.ts b/apps/game/src/systems/combat/__tests__/los.test.ts index 2cbbe93..3601c4c 100644 --- a/apps/game/src/systems/combat/__tests__/los.test.ts +++ b/apps/game/src/systems/combat/__tests__/los.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from 'vitest'; import { hasLineOfSight, tileOpaque } from '../los'; -import { TILES, buildMeadowsMap } from '../../../data/map'; - -/** Карта 10x10 с одной стеной в центре. */ -function mapWithWall(): { width: number; height: number; tiles: number[]; tall: Record } { - const tiles = new Array(100).fill(TILES.GRASS); - tiles[5 * 10 + 5] = TILES.HOUSE; // tall-объект - return { width: 10, height: 10, tiles, tall: { [TILES.HOUSE]: { height: 1, ground: 0 } } }; -} +import { buildMeadowsMap } from '../../../data/map'; +import { mapWithWall } from '../../../testing/maps'; describe('hasLineOfSight', () => { const opaque = tileOpaque({ data: mapWithWall() }); diff --git a/apps/game/src/testing/combatWorld.ts b/apps/game/src/testing/combatWorld.ts new file mode 100644 index 0000000..3784a9d --- /dev/null +++ b/apps/game/src/testing/combatWorld.ts @@ -0,0 +1,54 @@ +import type { AudioManager, EventBus, IsometricTileMap } from '@rpg/engine'; +import { CombatWorld } from '../systems/combat/CombatWorld'; +import { type EnemyBrain, type EnemySenses } from '../systems/combat/EnemyBrain'; + +/** + * Тестовые фикстуры боевого мира (без Pixi и браузера). Импортировать только + * из *.test.ts — в прод-бандл эти файлы не попадают. + */ + +/** Фейковая карта w×h травы, проходима всюду (по умолчанию 20×20). */ +export function fakeMap(w = 20, h = 20): IsometricTileMap { + const data = { + width: w, + height: h, + tiles: new Array(w * h).fill(0), + blocked: [], + tall: undefined + }; + return { data, isWalkable: () => true } as unknown as IsometricTileMap; +} + +/** Боевой мир над фейковой картой: события глушатся, герой в заданной точке. */ +export function fakeWorld(playerPos: { x: number; y: number }): CombatWorld { + return new CombatWorld({ + map: fakeMap(), + events: { emit: () => {} } as unknown as EventBus, + audio: { play: () => Promise.resolve() } as unknown as AudioManager, + getPlayerPos: () => playerPos, + damagePlayer: () => {} + }); +} + +/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ +export function run( + b: EnemyBrain, + seconds: number, + senses: EnemySenses +): ReturnType { + let intent: ReturnType = { type: 'idle' }; + for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); + return intent; +} + +/** Сенсоры «герой далеко и не виден» (для патрульных сценариев). */ +export function far(pos: { x: number; y: number }): EnemySenses { + return { + dist: 30, + dirToPlayer: { x: 1, y: 0 }, + pos, + canSee: false, + home: { x: 10.5, y: 7.5 }, + hpFraction: 1 + }; +} \ No newline at end of file diff --git a/apps/game/src/testing/gameState.ts b/apps/game/src/testing/gameState.ts new file mode 100644 index 0000000..e548941 --- /dev/null +++ b/apps/game/src/testing/gameState.ts @@ -0,0 +1,17 @@ +import type { GameState } from '@rpg/engine'; + +/** + * Фейковый GameState: флаги и вар-числа в памяти (без DOM и сейвов). + * Импортировать только из *.test.ts. + */ +export function fakeState(): GameState { + const flags = new Set(); + const vars = new Map(); + return { + setFlag: (f: string) => void flags.add(f), + hasFlag: (f: string) => flags.has(f), + clearFlag: (f: string) => void flags.delete(f), + setVar: (k: string, v: number) => void vars.set(k, Number(v)), + getNumber: (k: string) => vars.get(k) ?? 0 + } as unknown as GameState; +} \ No newline at end of file diff --git a/apps/game/src/testing/maps.ts b/apps/game/src/testing/maps.ts new file mode 100644 index 0000000..1adb0f9 --- /dev/null +++ b/apps/game/src/testing/maps.ts @@ -0,0 +1,13 @@ +import { TILES } from '../data/map'; + +/** Карта 10×10 травы с одной стеной (tall-объект) в центре — для LOS. */ +export function mapWithWall(): { + width: number; + height: number; + tiles: number[]; + tall: Record; +} { + const tiles = new Array(100).fill(TILES.GRASS); + tiles[5 * 10 + 5] = TILES.HOUSE; + return { width: 10, height: 10, tiles, tall: { [TILES.HOUSE]: { height: 1, ground: 0 } } }; +} \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index d6535c7..0e630cd 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -115,6 +115,14 @@ движковый + сценический слои — для предикатов над прогрессом используй `agent.waitFor`, а не `engine.waitFor`. +**Ключи предиката проверяются до исполнения**: top-level обращения `s.<ключ>` +сверяются со списком `AGENT_SNAPSHOT_KEYS` (`agent/snapshot.ts`; движковые — +`ENGINE_SNAPSHOT_KEYS` из `@rpg/engine`). Неизвестный ключ (обычно опечатка — +`s.heroo`) отклоняет ожидание сразу: `{ ok: false, error: 'неизвестный ключ…', +ticks: 0 }` — вместо тихого `false` на весь таймаут. Новый top-level ключ +снапшота добавляй и в список (анти-дрейф тест — `snapshot.test.ts`); вложенные +пути (`s.hero.tile.x`) не проверяются — проверяется только первый сегмент. + ## Контентная валидация (data/validate.ts) Runtime-валидаторы возвращают `Invariant[]` (`severity: 'error' | 'warn'`, diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 057c491..1446c5e 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -148,6 +148,10 @@ 3. Ввод — через мост в юнитах (`walkTo`/`tapTile`/`press`), не через реальные клики по CSS-пикселям. 4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов. +5. Опечатка в ключе предиката `waitFor` больше не тихий таймаут: мост сразу + вернёт `{ ok: false, error: 'неизвестный ключ…' }` (список — + `AGENT_SNAPSHOT_KEYS`). Если ждёшь `ok: false` без `error` в деталях — + смотрим `error`, а не таймаут. ## Ситуация: добавляю врага / меняю ИИ diff --git a/packages/engine/src/agent/EngineAgent.ts b/packages/engine/src/agent/EngineAgent.ts index 6a489b4..84ffd49 100644 --- a/packages/engine/src/agent/EngineAgent.ts +++ b/packages/engine/src/agent/EngineAgent.ts @@ -9,6 +9,21 @@ import { mergeInvariants } from './invariants'; /** + * Top-level ключи движкового слоя снапшота (см. snapshot()). Игра расширяет + * его своими слоями (AGENT_SNAPSHOT_KEYS) — валидатор предикатов waitFor + * сверяет обращения `s.<ключ>` с объединённым списком. + */ +export const ENGINE_SNAPSHOT_KEYS = [ + 'tick', + 'fps', + 'fixedStep', + 'scenes', + 'transitioning', + 'camera', + 'pointer' +] as const; + +/** * Движковый агентный мост: снапшоты, детерминированные шаги, инъекция ввода. * Никогда не бросает: каждый доступ — через safe() с try/catch, ошибка * становится слоем снапшота `{ error }`. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5294182..86c9e2e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -21,7 +21,7 @@ } from './core/easing'; // agent (инструменты ИИ-агента: снапшоты, шаги, ввод, инварианты) -export { EngineAgent } from './agent/EngineAgent'; +export { EngineAgent, ENGINE_SNAPSHOT_KEYS } from './agent/EngineAgent'; export { type AgentHost, type EngineSnapshot,