diff --git a/CLAUDE.md b/CLAUDE.md index 61df4e7..dfdcd83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,11 @@ npm run typecheck # tsc --noEmit для обоих пакетов npm run art # перегенерация пиксель-арта из tools/pixelart npm run maps # перегенерация карт-файлов (tools/maps + encodeMap) +npm run agent:check # полный прогон проверок через агентный мост (JSON) +node tools/agent.mjs run tools/checks/xxx.mjs # один сценарий проверки +node tools/agent.mjs snapshot --new-game # снапшот игры (JSON) node tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) +node tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост node tools/smoke-ponds.mjs # смоук перехода луга -> Серые пруды node tools/smoke-zvenets.mjs # смоук перехода луга -> Звенец node tools/smoke-quest.mjs # смоук диалога с Ирвином и сумки/журнала @@ -42,12 +46,14 @@ ### Где что лежит -- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `input.md`, `maps.md`, `ui-and-dialogue.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`. При изменении API движка обновляй соответствующий файл. +- `docs/llms.txt` — **точка входа для ИИ-агента**: карта всех док одной строкой на док. +- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `input.md`, `maps.md`, `ui-and-dialogue.md`, `cutscene.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`, `agent.md` (агентный мост), `practices.md` (живой документ практик агента). При изменении API движка обновляй соответствующий файл и `practices.md` (если появился новый приём) **в том же коммите**. - `docs/demo.md` — **внутренняя проектная дока среза**: матрица «подсистема движка → где показана в игре» + статус, боевая модель, архитектура, дорожная карта. - `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** - `docs/art-style.md` — арт-библия: палитра (32 цвета), размеры спрайтов, правила стиля, чеклист ассетов. - `packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). -- `apps/game/src/data/` — весь контент: карта (`map.ts`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`). +- `apps/game/src/data/` — весь контент: карта (`map.ts`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). +- `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). - `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). - `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox). - Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). @@ -79,6 +85,7 @@ - **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. - **Клик по тайлу вне экрана не работает**: если целевой тайл за краем канваса (виртуальный y > 270 или x < 0), клик уходит мимо канваса — герой не двигается. В смоук-скриптах целиться в промежуточный тайл рядом с героем по направлению к цели (шаг по доминирующей оси). - Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `tools/pixelart/palette.mjs`. +- **Агентный мост** (`window.__agent`, DEV): предпочитай `snapshot`/`agent:check` скриншотам — состояние игры читается снапшотом, скриншот только для визуальных вопросов. Fade-переход молча глотает параллельные `begin()` и клики (`transitioning`) — перед действием `waitFor('!s.transitioning')`. Инъекция ввода и шаг — атомарны (один JS-стек): высокоуровневые `tapTile/press/key/walkTo` моста уже атомарны, сырые `inject*` из страницы + отдельный шаг — гонка с rAF. Подробности: `docs/engine/agent.md`, приёмы: `docs/engine/practices.md` (пополнять в том же коммите). ## Рабочие привычки diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index c70d946..acbb51b 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -84,6 +84,8 @@ // Хук для смоук-тестов (tools/smoke*.mjs): доступ к контексту из страницы. if (import.meta.env.DEV) { (window as unknown as { __game?: Game }).__game = this; + // Агентный мост: window.__agent (см. docs/engine/agent.md, tools/agent.mjs). + import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this)); } } diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts new file mode 100644 index 0000000..7ea4452 --- /dev/null +++ b/apps/game/src/agent/GameAgent.ts @@ -0,0 +1,191 @@ +import { + EngineAgent, + worldToScreen, + type AgentHost, + type Invariant, + type JsonValue, + type SceneAgent, + type SnapshotLayer +} from '@rpg/engine'; +import type { Game } from '../Game'; +import { gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot'; +import { validateContent } from '../data/validate'; + +/** + * Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет + * контентные слои (флаги/вары/инвентарь/сцена) и помощники высокого уровня + * (ходьба по маршруту, диалоги, «новая игра»). Регистрируется как + * `window.__agent` (только DEV) — инструменты в tools/ ходят через него. + */ + +export interface AgentApi { + version: 1; + snapshot(): GameSnapshot; + invariants(): Invariant[]; + /** n фиксированных шагов (детерминированно, без реального ожидания). */ + step(n?: number, opts?: { render?: boolean }): { tick: number }; + /** pred — строка-выражение над снапшотом (из браузера функцию не передать). */ + waitFor( + pred: string, + opts?: { timeoutTicks?: number; render?: boolean } + ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }>; + /** Клик по тайлу (в юнитах; не зависит от видимости тайла на канвасе). */ + tapTile(tx: number, ty: number): void; + /** Клик в виртуальных пикселях (480×270). */ + tapVirtual(vx: number, vy: number): void; + /** Реальный DOM-клик (Pixi-кнопки меню). */ + uiTap(vx: number, vy: number): void; + /** Действие по маппингу (advance/attack/...). */ + press(action: string, holdTicks?: number): void; + /** Сырой код клавиши (e.code). */ + key(code: string): void; + /** Whitelist-команда сцены (scene:sleepAll, scene:teleport, ...). */ + command(name: string, args?: JsonValue): JsonValue; + /** Пойти в тайл по маршруту A* (в сцене). false — путь не найден. */ + walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise; + /** Дождаться и долистать активный диалог. false — диалог не открылся. */ + runDialogue(timeoutTicks?: number): Promise; + /** «Новая игра» из меню. */ + newGame(): Promise; + /** id текущей локации (null — сцена не локация). */ + currentArea(): string | null; +} + +/** Действие «листать диалог» (маппится и на Space, и на Enter). */ +const ADVANCE = 'advance'; + +export class GameAgent { + private engineAgent: EngineAgent; + + constructor(private game: Game) { + this.engineAgent = new EngineAgent( + game.engine as unknown as AgentHost, + () => (game.scenes.current as unknown as SceneAgent | undefined) ?? null + ); + } + + snapshot(): GameSnapshot { + const s = this.engineAgent.snapshot() as GameSnapshot; + // Слой игры поверх движкового и сценического. + Object.assign(s, gameLayer({ + flags: this.game.state.allFlags, + vars: { ...this.game.state.serialize().vars }, + inventory: this.game.inventory.all + })); + return s; + } + + invariants(): Invariant[] { + // Контентная валидация живёт здесь (не в сцене), чтобы работать и в меню. + return [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)]; + } + + step(n = 1, opts?: { render?: boolean }): { tick: number } { + return this.engineAgent.step(n, opts); + } + + async waitFor( + pred: string, + opts?: { timeoutTicks?: number; render?: boolean } + ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }> { + // Строка-выражение приходит из tools/ (доверенная среда репозитория). + const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; + return this.engineAgent.waitFor(fn, opts) as Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }>; + } + + /** Клик по тайлу: юниты -> экран (виртуальные px) -> инъекция указателя. */ + tapTile(tx: number, ty: number): void { + const w = worldToScreen(tx + 0.5, ty + 0.5); + const root = this.game.worldRoot.position; + this.tapVirtual(w.x + root.x, w.y + root.y); + } + + tapVirtual(vx: number, vy: number): void { + this.engineAgent.tapVirtual(vx, vy); + } + + uiTap(vx: number, vy: number): void { + this.engineAgent.uiTap(vx, vy); + } + + press(action: string, holdTicks = 1): void { + this.engineAgent.press(action, holdTicks); + } + + key(code: string): void { + this.engineAgent.key(code); + } + + command(name: string, args?: JsonValue): JsonValue { + return this.engineAgent.command(name, args) as JsonValue; + } + + /** Идти в тайл: маршрут просит сцену (scene:route), кликает по узлам по очереди. */ + async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise { + const route = this.command('scene:route', { x: tx, y: ty }); + if (!Array.isArray(route) || route.length === 0) return false; + for (const node of route as { x: number; y: number }[]) { + this.tapTile(node.x, node.y); + const wait = await this.waitFor( + `s.hero && s.hero.tile && s.hero.tile.x === ${node.x} && s.hero.tile.y === ${node.y}`, + { timeoutTicks: opts?.timeoutTicks ?? 240 } + ); + if (!wait.ok) return false; + } + return true; + } + + /** Долистать активный диалог: жать advance, пока он открыт. */ + async runDialogue(timeoutTicks = 600): Promise { + const opened = await this.waitFor('s.dialogue != null', { timeoutTicks }); + if (!opened.ok) return false; + for (let i = 0; i < 50; i++) { + const d = this.snapshot().dialogue as DialogueSnapshot | null; + if (!d) return true; // диалог закрылся — завершён + if (d.waitingForChoice) { + this.command('scene:pickChoice', { index: 0 }); + } else { + this.press(ADVANCE); + } + this.step(2); + } + return !this.snapshot().dialogue; + } + + /** «Новая игра» из меню (команда сцены — без координат кнопок). */ + async newGame(): Promise { + // begin() молча отбрасывает команды во время fade-перехода — ждём его конца. + await this.waitFor('!s.transitioning', { timeoutTicks: 1200 }); + this.command('menu:newGame'); + await this.waitFor('s.scene === "location"', { timeoutTicks: 1200 }); + } + + currentArea(): string | null { + const s = this.snapshot(); + return typeof s.area === 'string' ? s.area : null; + } +} + +/** Зарегистрировать window.__agent (только DEV). */ +export function registerAgent(game: Game): AgentApi { + const agent = new GameAgent(game); + const api: AgentApi = { + version: 1, + snapshot: () => agent.snapshot(), + invariants: () => agent.invariants(), + step: (n, opts) => agent.step(n, opts), + waitFor: (pred, opts) => agent.waitFor(pred, opts), + tapTile: (tx, ty) => agent.tapTile(tx, ty), + tapVirtual: (vx, vy) => agent.tapVirtual(vx, vy), + uiTap: (vx, vy) => agent.uiTap(vx, vy), + press: (action, holdTicks) => agent.press(action, holdTicks), + key: (code) => agent.key(code), + command: (name, args) => agent.command(name, args), + walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts), + runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), + newGame: () => agent.newGame(), + currentArea: () => agent.currentArea() + }; + (window as unknown as { __agent?: AgentApi }).__agent = api; + return api; +} \ No newline at end of file diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts new file mode 100644 index 0000000..21a6346 --- /dev/null +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + dialogueLayer, + enemiesLayer, + gameLayer, + heroLayer, + npcsLayer, + type EnemySnapshot, + type NpcSnapshot +} from '../snapshot'; + +describe('снапшот агента — слои', () => { + it('герой: позиция округляется до 3 знаков, остальное копируется', () => { + const layer = heroLayer({ + tile: { x: 12, y: 8 }, + pos: { x: 1.23456, y: 5.0001 }, + hp: 9, + maxHp: 12, + facing: 'side', + moving: false, + invuln: false, + inHazard: null + }); + expect(layer.hero).toMatchObject({ + tile: { x: 12, y: 8 }, + pos: { x: 1.235, y: 5 }, + hp: 9, + maxHp: 12, + facing: 'side' + }); + }); + + it('враги: поля на месте, позиция округлена', () => { + const e: EnemySnapshot = { + kind: 'ash', + state: 'chase', + hp: 4, + pos: { x: 0.123456, y: 2.5 }, + asleep: true, + dead: false + }; + const layer = enemiesLayer([e]); + expect(layer.enemies).toEqual([ + { kind: 'ash', state: 'chase', hp: 4, pos: { x: 0.123, y: 2.5 }, asleep: true, dead: false } + ]); + }); + + 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]!; + expect(out).toEqual(n); + expect(out).not.toBe(n); + }); + + it('диалог: объект проходит, null остаётся null', () => { + const d = { id: 'elder', nodeId: 'n1', speaker: 'Ирвин', text: 'Привет', choices: [], waitingForChoice: false }; + expect(dialogueLayer(d).dialogue).toEqual(d); + expect(dialogueLayer(null).dialogue).toBeNull(); + }); + + it('слой игры: флаги/вары/сумка копируются защитно', () => { + const flags = ['metElder']; + const vars = { flowers: 2 }; + const inv = [{ id: 'flower', count: 2 }]; + const layer = gameLayer({ flags, vars, inventory: inv }); + expect(layer).toEqual({ flags: ['metElder'], vars: { flowers: 2 }, inventory: [{ id: 'flower', count: 2 }] }); + // Мутация источника не меняет слой. + flags.push('x'); + vars.flowers = 99; + inv[0]!.count = 99; + expect(layer.flags).toEqual(['metElder']); + expect(layer.vars).toEqual({ flowers: 2 }); + expect(layer.inventory[0]!.count).toBe(2); + }); +}); \ No newline at end of file diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts new file mode 100644 index 0000000..dcceffe --- /dev/null +++ b/apps/game/src/agent/snapshot.ts @@ -0,0 +1,144 @@ +import type { SnapshotLayer, JsonValue } from '@rpg/engine'; + +/** + * Контентный слой снапшота агентного моста. Чистые функции — тестируются + * в Vitest без браузера; LocationScene/GameAgent подставляют живые данные. + */ + +/** Герой. */ +export interface HeroSnapshot { + tile: { x: number; y: number }; + pos: { x: number; y: number }; + hp: number; + maxHp: number; + facing: string; + moving: boolean; + invuln: boolean; + inHazard: string | null; +} + +/** Враг. */ +export interface EnemySnapshot { + kind: string; + state: string | null; + hp: number; + pos: { x: number; y: number }; + asleep: boolean; + dead: boolean; +} + +/** NPC. */ +export interface NpcSnapshot { + id: string; + name: string; + tile: { x: number; y: number }; + met: boolean; +} + +/** Активный диалог (или null). */ +export interface DialogueSnapshot { + id: string | null; + nodeId: string | null; + speaker: string | null; + text: string | null; + choices: string[]; + waitingForChoice: boolean; +} + +/** Переход (дверь/выход/портал) — из данных локации. */ +export interface TransitionSnapshot { + tile: { x: number; y: number }; + to: string; + label: string | null; +} + +/** Контентный слой сцены локации. */ +export interface LocationSnapshot { + scene: 'location'; + area: string; + areaName: string; + hero: HeroSnapshot; + enemies: EnemySnapshot[]; + npcs: NpcSnapshot[]; + transitions: TransitionSnapshot[]; + dialogue: DialogueSnapshot | null; + cutscene: { active: boolean } | null; + /** Последний тост (текст + тик) — единственный канал текста реакций. */ + lastToast: { text: string; tick: number } | null; +} + +/** Полный снапшот игры: движковый слой + слои игры. */ +export interface GameSnapshot { + [k: string]: JsonValue | unknown; + /** Флаги прохождения. */ + flags: string[]; + /** Переменные прохождения. */ + vars: Record; + /** Сумка. */ + inventory: { id: string; count: number }[]; +} + +/** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */ +function r(v: number): number { + return Math.round(v * 1000) / 1000; +} + +/** Слой героя. */ +export function heroLayer(o: HeroSnapshot): SnapshotLayer { + return { + hero: { + tile: { x: o.tile.x, y: o.tile.y }, + pos: { x: r(o.pos.x), y: r(o.pos.y) }, + hp: o.hp, + maxHp: o.maxHp, + facing: o.facing, + moving: o.moving, + invuln: o.invuln, + inHazard: o.inHazard + } + }; +} + +/** Слой врагов. */ +export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer { + return { + enemies: enemies.map((e) => ({ + kind: e.kind, + state: e.state, + hp: e.hp, + 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 { + 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 gameLayer(o: { + flags: string[]; + vars: Record; + inventory: { id: string; count: number }[]; +}): GameSnapshot { + return { + flags: [...o.flags], + vars: { ...o.vars }, + inventory: o.inventory.map((s) => ({ id: s.id, count: s.count })) + }; +} \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts new file mode 100644 index 0000000..e8c2e16 --- /dev/null +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import type { DialogueGraph, TileMapData } from '@rpg/engine'; +import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map'; +import { LOCATIONS } from '../locations'; +import { validateContent, validateDialogue, validateLocations, validateNpcs } from '../validate'; + +/** Карта целиком из проходимой травы (или с одиночной стеной). */ +function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData { + const tiles = new Array(w * h).fill(TILES.GRASS); + if (wall) tiles[wall.y * w + wall.x] = TILES.HOUSE; + return { width: w, height: h, tiles, blocked: [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE] }; +} + +/** Карты всех локаций (как их грузит BootScene). */ +function realMaps(): Map { + return new Map([ + ['meadows', buildMeadowsMap()], + ['ponds', buildPondsMap()], + ['zvenets', buildZvenetsMap()] + ]); +} + +describe('validateContent — реальный контент чист', () => { + it('ошибок нет (warn допустим)', () => { + const errs = validateContent(realMaps()).filter((i) => i.severity === 'error'); + expect(errs).toEqual([]); + }); +}); + +describe('validateDialogue', () => { + it('битый start и битый next — ошибки', () => { + const g: DialogueGraph = { + start: 'нет', + nodes: { + a: { text: 'a', next: 'нет2' }, + b: { text: 'b', choices: [{ text: 'x', next: 'нет3' }] } + } + }; + const ids = validateDialogue('test', g).map((i) => i.id); + expect(ids).toContain('dialogue-start'); + expect(ids).toContain('dialogue-next'); + }); + + it('узел-сирота — warn, связный граф чист', () => { + const ok: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', next: 'b' }, b: { text: 'b', end: true } } }; + expect(validateDialogue('ok', ok)).toEqual([]); + const orphan: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', end: true }, dead: { text: 'x', end: true } } }; + expect(validateDialogue('orphan', orphan).map((i) => i.id)).toEqual(['dialogue-orphan']); + }); +}); + +describe('validateNpcs', () => { + it('проходимая карта без NPC в стенах — чисто; стена под NPC ловится', () => { + const maps = new Map([['zvenets', flatMap(28, 20)]]); + expect(validateNpcs(maps)).toEqual([]); + // NPCS статичен — подменяем карту: стена на тайле Ирвина даст 'in-wall'. + const elder = LOCATIONS.zvenets.npcs[0]!; + const walled = new Map([['zvenets', flatMap(28, 20, { x: elder.tile.x, y: elder.tile.y })]]); + const inv = validateNpcs(walled); + expect(inv.some((i) => i.id === 'in-wall' && i.message.includes(elder.id))).toBe(true); + }); +}); + +describe('validateLocations', () => { + it('exit в несуществующую локацию — ошибка exit-target', () => { + const maps = new Map([['meadows', flatMap(28, 28)]]); + // LOCATIONS статичен; проверяем чистую функцию через его реальные данные, + // а битую цель эмулируем картой без пары: meadows -> ponds существует, + // но ponds в maps нет — exit-target не сработает, сработает отсутствие. + const inv = validateLocations(maps); + // meadows ссылается на ponds/zvenets — их карт нет: ошибок target нет, + // но и падений нет (guard на отсутствующую карту). + expect(inv.every((i) => i.severity === 'warn' || i.where?.includes('meadows'))).toBe(true); + }); + + it('спавн в стене ловится', () => { + const spawn = LOCATIONS.meadows.spawn; + const maps = new Map([['meadows', flatMap(28, 28, spawn)]]); + const inv = validateLocations(maps); + expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index ebf2bef..600d661 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -54,7 +54,7 @@ enemies: [ { kind: 'crawler', tile: { x: 8, y: 9 } }, { kind: 'crawler', tile: { x: 10, y: 7 } }, - { kind: 'spitter', tile: { x: 20, y: 8 } }, + { kind: 'spitter', tile: { x: 19, y: 6 } }, { kind: 'crawler', tile: { x: 22, y: 21 } }, { kind: 'heavy', tile: { x: 6, y: 22 } } ], @@ -78,11 +78,11 @@ spawn: { x: 3, y: 3 }, npcs: [], enemies: [ - { kind: 'crawler', tile: { x: 5, y: 12 } }, + { kind: 'crawler', tile: { x: 4, y: 11 } }, { kind: 'crawler', tile: { x: 9, y: 18 } }, - { kind: 'spitter', tile: { x: 16, y: 11 } }, + { kind: 'spitter', tile: { x: 18, y: 10 } }, { kind: 'spitter', tile: { x: 18, y: 18 } }, - { kind: 'heavy', tile: { x: 12, y: 15 } } + { kind: 'heavy', tile: { x: 13, y: 17 } } ], exits: [ // Обратно на луга. diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts new file mode 100644 index 0000000..6567f88 --- /dev/null +++ b/apps/game/src/data/validate.ts @@ -0,0 +1,137 @@ +import { + checkBounds, + checkWalkable, + mergeInvariants, + type Grid, + type Invariant, + type TileMapData, + type DialogueGraph +} from '@rpg/engine'; +import { LOCATIONS } from './locations'; +import { NPCS } from './npcs'; +import { DIALOGUES } from './dialogues'; +import { ENEMY_KINDS } from './enemies'; + +/** + * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — + * в TS-типах; здесь ловим то, что типы не выражают: ссылки, границы, стены). + * Вызывается из GameAgent.invariants() и из юнит-теста — битый контент падает + * сразу в тестах, а не в рантайме игры. + */ + +const WHERE = 'data'; + +/** Grid-адаптер над сырыми данными карты (для checkBounds/checkWalkable/A*). */ +function gridOf(data: TileMapData): Grid { + return { + width: data.width, + height: data.height, + isWalkable: (x, y) => + x >= 0 && y >= 0 && x < data.width && y < data.height && + !data.blocked.includes(data.tiles[y * data.width + x]) + }; +} + +/** Граф диалога: start/next/choices существуют, узлы-сироты (warn). */ +export function validateDialogue(id: string, graph: DialogueGraph): Invariant[] { + const out: Invariant[] = []; + const where = `${WHERE}/dialogue/${id}`; + if (!graph.nodes[graph.start]) { + out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where }); + } + for (const [nodeId, node] of Object.entries(graph.nodes)) { + for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) { + if (next !== undefined && !graph.nodes[next]) { + out.push({ id: 'dialogue-next', severity: 'error', message: `узел «${nodeId}»: next «${next}» не существует`, where }); + } + } + } + // Сироты: узлы, в которые нет ссылки (warn — могут быть «мёртвым ветвлением»). + const referenced = new Set([graph.start]); + for (const node of Object.values(graph.nodes)) { + for (const next of (node.choices ?? []).map((c) => c.next).concat(node.next)) { + if (next !== undefined) referenced.add(next); + } + } + for (const nodeId of Object.keys(graph.nodes)) { + if (!referenced.has(nodeId)) { + out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${nodeId}» недостижим`, where }); + } + } + return out; +} + +/** NPC: не в стенах, в границах своей локации. */ +export function validateNpcs(maps: Map): Invariant[] { + const out: Invariant[] = []; + for (const npc of NPCS) { + for (const loc of Object.values(LOCATIONS)) { + if (!loc.npcs.some((n) => n.id === npc.id)) continue; + const map = maps.get(loc.id); + if (!map) continue; + const grid = gridOf(map); + const check = mergeInvariants( + checkBounds(`NPC ${npc.id}`, npc.tile, map.width, map.height, WHERE), + checkWalkable(`NPC ${npc.id}`, npc.tile, grid, WHERE) + ); + out.push(...check); + } + } + return out; +} + +/** Локации: спавн/враги проходимы, exits ведут в существующие области и проходимые entry. */ +export function validateLocations(maps: Map): Invariant[] { + const out: Invariant[] = []; + for (const loc of Object.values(LOCATIONS)) { + const map = maps.get(loc.id); + if (!map) continue; + const grid = gridOf(map); + const where = `${WHERE}/location/${loc.id}`; + out.push(...mergeInvariants( + checkBounds('spawn', loc.spawn, map.width, map.height, where), + checkWalkable('spawn', loc.spawn, grid, where) + )); + loc.enemies.forEach((e, i) => { + out.push(...mergeInvariants( + checkBounds(`враг#${i}`, e.tile, map.width, map.height, where), + checkWalkable(`враг#${i}`, e.tile, grid, where) + )); + }); + for (const exit of loc.exits) { + if (!LOCATIONS[exit.to]) { + out.push({ id: 'exit-target', severity: 'error', message: `exit -> «${exit.to}»: локация не существует`, where }); + continue; + } + const targetMap = maps.get(exit.to); + if (targetMap) { + out.push(...mergeInvariants( + checkBounds(`exit entry (${exit.to})`, exit.entry, targetMap.width, targetMap.height, where), + checkWalkable(`exit entry (${exit.to})`, exit.entry, gridOf(targetMap), where) + )); + } + } + } + return out; +} + +/** Виды врагов: базовая числовая согласованность. */ +export function validateEnemies(): Invariant[] { + const out: Invariant[] = []; + for (const kind of Object.values(ENEMY_KINDS)) { + const where = `${WHERE}/enemy/${kind.id}`; + if (kind.hp <= 0) out.push({ id: 'enemy-hp', severity: 'error', message: `hp = ${kind.hp}`, where }); + if (kind.speed < 0) out.push({ id: 'enemy-speed', severity: 'error', message: `speed = ${kind.speed}`, where }); + } + return out; +} + +/** Весь контент разом (для снапшота моста и тестов). */ +export function validateContent(maps: Map): Invariant[] { + return mergeInvariants( + ...Object.entries(DIALOGUES).map(([id, g]) => validateDialogue(id, g)), + validateNpcs(maps), + validateLocations(maps), + validateEnemies() + ); +} \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index ce3c8f1..9cd3ab4 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -14,13 +14,21 @@ worldToTile, tileToWorld, inCircleW, + findPath, findPathToNeighbor, + checkFinite, + checkRange, + checkWalkable, + mergeInvariants, DebugOverlay, SpriteDebugView, VirtualJoystick, type Camera, type Entity, + type Invariant, + type JsonValue, type Scene, + type SnapshotLayer, type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; @@ -41,6 +49,13 @@ import { PlayerCombat } from '../systems/combat/PlayerCombat'; import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; +import type { + HeroSnapshot, + EnemySnapshot, + NpcSnapshot, + TransitionSnapshot, + LocationSnapshot +} from '../agent/snapshot'; /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. @@ -86,6 +101,8 @@ private joystick: VirtualJoystick; private debug: DebugOverlay; private charDebug: SpriteDebugView; + /** Последний тост (текст + тик) — канал текста для агентного моста. */ + private lastToast: { text: string; tick: number } | null = null; constructor( private game: Game, @@ -490,6 +507,131 @@ } } + // ---------- агентный мост (SceneAgent) ---------- + + /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ + agentSnapshot(): SnapshotLayer { + const hero: HeroSnapshot = { + tile: this.player.currentTile(), + pos: this.player.position, + hp: this.playerCombat.hp, + maxHp: PLAYER_COMBAT.maxHp, + facing: this.player.dir, + moving: this.player.moving, + invuln: this.playerCombat.invuln, + inHazard: this.inHazard?.name ?? null + }; + const enemies: EnemySnapshot[] = []; + for (const [, en] of this.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[] = this.npcs.map(({ def }) => ({ + id: def.id, + name: def.name, + tile: def.tile, + met: this.game.state.hasFlag(def.flagKey) + })); + const transitions: TransitionSnapshot[] = this.location.exits.map((e) => ({ + tile: e.tile, + to: e.to, + label: null + })); + const layer: LocationSnapshot = { + scene: 'location', + area: this.location.id, + areaName: this.location.name, + hero, + enemies, + npcs, + transitions, + dialogue: this.dialogue.agentState, + cutscene: { active: this.cutscene.active }, + lastToast: this.lastToast + }; + return layer as unknown as SnapshotLayer; + } + + /** Инварианты сцены: валидность контента + целостность героя/врагов. */ + agentInvariants(): Invariant[] { + const where = 'scene/LocationScene'; + const heroTile = this.player.currentTile(); + const heroPos = this.player.position; + const enemyPos: Record = {}; + const enemyChecks: Invariant[] = []; + for (const [e, en] of this.combat.enemies) { + enemyPos[`enemy#${e}.x`] = en.pos.x; + enemyPos[`enemy#${e}.y`] = en.pos.y; + if (!en.brain.dead && !this.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', this.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), + checkWalkable('герой', heroTile, this.map, where), + enemyChecks + ); + } + + /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ + agentCommand(name: string, args?: JsonValue): JsonValue { + const a = (args ?? {}) as { x?: number; y?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number }; + switch (name) { + case 'scene:sleepAll': + for (const [, en] of this.combat.enemies) en.brain.putToSleep(9999); + return true; + case 'scene:give': + if (typeof a.id !== 'string') return null; + this.game.inventory.add(a.id); + return true; + case 'scene:setVar': + if (typeof a.id !== 'string') return null; + this.game.state.setVar(a.id, a.value ?? 0); + return true; + case 'scene:setFlag': + if (typeof a.flag !== 'string') return null; + this.game.state.setFlag(a.flag); + return true; + case 'scene:teleport': { + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + this.player.teleportTo({ x: a.x, y: a.y }); + this.updateCameraFollow(true); + return true; + } + case 'scene:route': { + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + const path = findPath(this.map, this.player.currentTile(), { x: a.x, y: a.y }, false); + return path ?? null; + } + case 'scene:pickChoice': + if (typeof a.index !== 'number') return null; + this.dialogue.pickChoice(a.index); + return true; + case 'scene:skipCutscene': { + if (!this.cutscene.active) return false; + while (this.cutscene.active) this.cutscene.update(0.5); + return true; + } + default: + return null; + } + } + // ---------- остальное ---------- private updateCameraFollow(snap = false): void { @@ -743,6 +885,7 @@ /** Всплывающая подсказка: появляется и растворяется над UI. */ private showToast(text: string): void { + this.lastToast = { text, tick: this.game.engine.tickCount }; const toast = new PixelText({ text, size: 11, color: 0xd8c79a }); toast.anchor.set(0.5); toast.position.set(240, 40); diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 57ece25..e2b391e 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,4 +1,13 @@ -import { Container, MenuList, PixelText, type GameStateData, type Scene } from '@rpg/engine'; +import { + Container, + MenuList, + PixelText, + type GameStateData, + type Invariant, + type JsonValue, + type Scene, + type SnapshotLayer +} from '@rpg/engine'; import type { Game } from '../Game'; import { locationOf } from '../data/locations'; import { LocationScene } from './LocationScene'; @@ -40,9 +49,7 @@ // Звук не блокирует навигацию: в suspended-контексте play может не дойти. onSelect: () => { void this.game.audio.play('sfx/ui_click'); - this.game.state.reset(); - this.game.inventory.clear(); - this.start(null); + this.startNewGame(); } }, { @@ -99,6 +106,31 @@ this.game.renderer.uiRoot.addChild(this.view); } + /** Новая игра: сброс прогресса и сумки, старт на лугах. */ + private startNewGame(): void { + this.game.state.reset(); + this.game.inventory.clear(); + this.start(null); + } + + // --- агентный мост: сцена отдаёт снапшот и принимает команды --- + + agentSnapshot(): SnapshotLayer { + return { scene: 'menu' }; + } + + agentInvariants(): Invariant[] { + return []; + } + + agentCommand(name: string): JsonValue { + if (name === 'menu:newGame') { + this.startNewGame(); + return true; + } + return null; + } + private start(save: SaveData | null): void { if (save) { const norm = normalizeSave(save); diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index 1e1d917..854b300 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -58,4 +58,30 @@ advance(): void { this.runner.advance(); } + + /** Выбрать показанный вариант (агентный мост; индексы — из agentState.choices). */ + pickChoice(index: number): void { + this.runner.pick(index); + } + + /** Состояние диалога для агентного моста (снапшот; не для логики). */ + get agentState(): { + id: string | null; + nodeId: string | null; + speaker: string | null; + text: string | null; + choices: string[]; + waitingForChoice: boolean; + } | null { + if (!this.runner.active) return null; + const node = this.runner.node; + return { + id: this.currentId, + nodeId: this.runner.nodeId, + speaker: node?.speaker ?? null, + text: node?.text ?? null, + choices: this.runner.choices.map((c) => c.text), + waitingForChoice: this.runner.waitingForChoice + }; + } } \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 53ea9b4..a75e916 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -34,6 +34,9 @@ | math/shapes (cone/circle) | конус удара, круг цели, кольца | готово | | Cooldown | кулдауны удара/резонанса/атак врагов | готово | | math/rng (mulberry32) | генерация карт и частиц, детерминированный shake | готово | +| AgentHost/EngineAgent (agent/) | `window.__agent`: снапшот, ручной шаг, инъекция ввода, инварианты | готово | +| data/validate.ts → Invariant[] | runtime-валидация контента (диалоги/NPC/локации/враги) | готово | +| tools/agent.mjs + tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту | готово | ## Сюжетная рамка (акт 1, по docs/world.md) @@ -65,7 +68,9 @@ - `systems/` — PlayerController (путь + прямое движение), DialogueSystem, `combat/` (CombatWorld, EnemyBrain, PlayerCombat, CombatViews, HealthBar, stats). - Инструменты: `tools/pixelart` (PNG-атласы), `tools/audio` (WAV-генератор), - `tools/maps` (карты-файлы), `tools/smoke*.mjs` (смоук в реальном Chromium). + `tools/maps` (карты-файлы), `tools/agent.mjs` + `tools/agent-lib.mjs` + + `tools/checks/` (агентный мост: check/snapshot/сценарии), `tools/smoke*.mjs` + (смоуки через мост, в реальном Chromium). ## Новое в движке за срез @@ -83,11 +88,36 @@ - `cutscene/CutsceneRunner.ts` — view-агностичный раннер шагов (`call/cameraMove/burst/wait/setFlag`), шаг с `seconds: 0` — параллельный; сцена ставит геймплей на паузу по `active`. +## Новое в движке за срез (агентный набор инструментов) + +- `agent/types.ts` — `SceneAgent`/`AgentHost`/`Invariant`/`SnapshotLayer`: движковый + каркас моста жанронезависим (по образцу `StorageLike`), тесты без Pixi. +- `agent/EngineAgent.ts` — снапшот (движковый слой + сцена), инварианты, ручной шаг + (`step`/`waitFor`), атомарная инъекция ввода (`tapVirtual`/`press`/`key`), команды. +- `core/Engine.stepTick/stepTicks/tickCount` + `GameLoop.setManual/resetTiming` — + детерминированные шаги логики без «догоняния» rAF. +- `input/InputManager.inject*` — инъекция через те же структуры, что реальные события. +- `dialogue/DialogueRunner` — раскрыт для снапшота (`nodeId/choices/waitingForChoice`). +- Игра: `agent/GameAgent.ts` (`window.__agent`), `agent/snapshot.ts`, `data/validate.ts`, + сцены реализуют `SceneAgent` (whitelist-команды, `lastToast`). + ## Дорожная карта (что осталось за срезом) -**Следующий срез — строительный набор систем (запрос игрока):** +**Текущий срез — движок для ИИ-агента + геймплей на нём (план утверждён):** -- **Телепортация**: точки перехода внутри локации (порталы/колодцы) и между +- ✅ **Батч 0 — агентный набор инструментов** (готово): мост `window.__agent`, + валидация контента, `tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + + `docs/engine/agent.md` + `docs/engine/practices.md`. +- **Батч 1**: единый механизм переходов (step/click), дисарм, `returnTo`, + сейвы v3, телепорт-колодец. +- **Батч 2**: интерьеры (дом Ирвина, лавка Милы) + интерактивные объекты. +- **Батч 3**: взаимодействие с миром шире (знаки/подбор/рычаг, мот-песок). +- **Батч 4**: ИИ врагов (LOS/шум/патруль/wary/flee/return). +- Принцип среза: каждая геймплейная система имеет сценарий в `tools/checks/`. + +**Дальше (запрос игрока, после моста):** + +- **Телепортация (геймплей)**: точки перехода внутри локации (порталы/колодцы) и между локациями без «пешего» перехода (витрина SceneManager + fade). - **Здания с интерьерами**: вход в дома (двери-переходы), интерьерные карты, объекты внутри (сундук, лавка). diff --git a/docs/engine/README.md b/docs/engine/README.md index 4c5c7b9..a191b73 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -17,6 +17,8 @@ | [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | | [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | | [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | +| [agent.md](agent.md) | Агентный мост: EngineAgent, window.__agent, инварианты, tools/agent.mjs | +| [practices.md](practices.md) | Живой документ практик агента «по ситуациям» — пополняется в каждом коммите | ## Принципы @@ -71,6 +73,7 @@ audio/ AudioManager (шины master/music/sfx, кроссфейд) assets/ AssetLoader (текстуры, атласы) save/ SaveManager (JSON-слоты) + agent/ EngineAgent, SceneAgent, инварианты (агентный мост) ecs/ World/createEntity/query/addSystem debug/ DebugOverlay ``` diff --git a/docs/engine/agent.md b/docs/engine/agent.md new file mode 100644 index 0000000..01e5813 --- /dev/null +++ b/docs/engine/agent.md @@ -0,0 +1,129 @@ +# Агентный мост (EngineAgent + window.__agent) + +Агентный мост — набор инструментов, позволяющий ИИ-агенту (или тесту) управлять +игрой и читать её состояние **без скриншотов и «слепых» кликов по CSS-пикселям**. +Мост не заменяет рендер и реальный ввод, а идёт поверх них: инъекция ввода проходит +через те же структуры `InputManager`, шаг логики — через тот же `GameLoop`. + +## Слои + +``` +packages/engine/src/agent/ — движковый каркас (жанронезависимый) + types.ts JsonValue, SnapshotLayer, Invariant, SceneAgent, AgentHost + invariants.ts чистые хелперы: checkFinite/checkRange/checkBounds/checkWalkable + EngineAgent.ts снапшот, инварианты, ручной шаг, инъекция ввода, waitFor + +apps/game/src/agent/ — контентный слой игры + snapshot.ts чистые сборщики слоёв (hero/enemies/npcs/dialogue/game) — Vitest + GameAgent.ts регистрирует window.__agent (только DEV), high-level хелперы + +apps/game/src/data/validate.ts — runtime-валидация контента → Invariant[] +tools/agent-lib.mjs — браузерный клиент (puppeteer, openGame, Checks) +tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev +``` + +Граница: движок не знает ничего об RPG-контенте. `EngineAgent` общается со сценой +через интерфейс `SceneAgent` (по образцу `StorageLike`), что позволяет тестировать +каркас в Vitest без Pixi и браузера. + +## SceneAgent — что сцена отдаёт мосту + +```ts +interface SceneAgent { + agentSnapshot(): SnapshotLayer; // слой сцены в снапшот + agentInvariants(): Invariant[]; // инварианты сцены + agentCommand?(name: string, args?: JsonValue): JsonValue; // whitelist-команды +} +``` + +`LocationScene` отдаёт: `scene/area/areaName`, `hero {tile, pos, hp, facing, moving, +invuln, inHazard}`, `enemies [{kind, state, hp, pos, asleep, dead}]`, `npcs`, +`transitions`, `dialogue {id, nodeId, text, choices, waitingForChoice} | null`, +`cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе +агенту нужен OCR). `MenuScene` отдаёт `{scene: 'menu'}` и команду `menu:newGame`. + +Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): +`scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, +`scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, +`scene:skipCutscene`. Команда вне whitelist возвращает `null` — расширять осознанно. + +## window.__agent (только DEV) + +```ts +interface AgentApi { + version: 1; + snapshot(): GameSnapshot; // движковый слой + сцена + флаги/вары/сумка + invariants(): Invariant[]; // движковые + сценические + контентные + step(n?, {render}?): {tick}; // n фиксированных шагов (60 Гц), рендер опционален + waitFor(pred: string, opts?): Promise<{ok, snapshot, ticks}>; + tapTile(tx, ty); tapVirtual(vx, vy); uiTap(vx, vy); + press(action, holdTicks?); key(code); + command(name, args?): JsonValue; + walkTo(tx, ty, opts?): Promise; // маршрут A* + клики по узлам + runDialogue(timeoutTicks?): Promise;// листает диалог до конца + newGame(): Promise; + currentArea(): string | null; +} +``` + +- **pred — строка-выражение** над снапшотом (`'s.hero.hp < 3'`): из браузера нельзя + передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (tools/). +- **waitFor крутит шаги сам** и вызывает pred после каждого шага; сцены могут + меняться в процессе — обращайся к полям через optional chaining. +- Мост **никогда не бросает исключений**: ошибка внутри превращается в слой + `{error: string}` снапшота (проверяй `s.error`). + +## Ручной шаг и атомарность инъекции + +`Engine.stepTick()` — один шаг логики (60 Гц) + `events.emit('engine:tick')`; +`stepTicks(n, render)` — серия с `GameLoop.setManual(true)` и `resetTiming()` после, +чтобы rAF не «догонял» пропущенное. Реальный rAF-цикл при этом живёт: между +вызовами из страницы кадры продолжают тикать. + +Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть +атомарны (один JS-стек). `tapVirtual/press/key` внутри делают инъекцию + один +`stepTick` — между `evaluate`-вызовами rAF успел бы очистить «just pressed». +`walkTo` тоже атомарен по узлам: клик + ожидание тайла. + +Семантика «just pressed» не меняется: инъекция между тиками видна сцене ровно +один тик (очистка в `endTick`). + +## waitFor и переходы сцен + +`SceneManager.begin()` молча отбрасывает переход, если уже идёт другой, а сцена +игнорирует клики, пока `transitioning`. Поэтому: + +- перед действием из меню: `await agent.waitFor('!s.transitioning')`; +- после входа в локацию: тоже дождаться `!s.transitioning` — fade длится + 2 × duration, снапшот `scene: 'location'` появляется уже на swap. + +## Контентная валидация (data/validate.ts) + +Runtime-валидаторы возвращают `Invariant[]` (`severity: 'error' | 'warn'`, +`where` — путь к данным). Вместо JSON Schema: истина одна — TS-типы, валидатор +ловит то, что типы не выражают (ссылки, границы, стены). Проверяются: графы +диалогов (start/next/choices существуют, сироты — warn), NPC/спавн/враги не в +стенах, exits ведут в существующие области и проходимые entry, hp/speed врагов. + +Вызывается из `GameAgent.invariants()` (работает и в меню) и из юнит-теста +`data/__tests__/validate.test.ts` — битый контент падает сразу в тестах. + +## tools/agent.mjs (CLI, JSON по умолчанию) + +```bash +node tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] # полный прогон +node tools/agent.mjs run tools/checks/xxx.mjs # один сценарий +node tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] +node tools/agent.mjs screenshot [--out] [--steps N] # скриншот + хвост консоли +node tools/agent.mjs dev [--port 5199] # dev-сервер для ручной работы +``` + +`check` поднимает dev-сервер сам (и гасит в finally): `typecheck`, `tests`, `maps`, +`maps:fresh` (файлы карт равны генераторам), `agent:invariants` (новая игра → 300 +шагов → инварианты чисты). Сценарии геймплея — в `tools/checks/*.mjs`, каркас — +`Checks` из `tools/agent-lib.mjs` (проверка возвращает `false`/кидает исключение +или `c.expect(cond, msg, details)`). + +**Правило**: каждая геймплейная система обязана иметь сценарий проверки в +`tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для +визуальных вопросов (арт, компоновка), состояние игры читается снапшотом. \ No newline at end of file diff --git a/docs/engine/practices.md b/docs/engine/practices.md new file mode 100644 index 0000000..b237eea --- /dev/null +++ b/docs/engine/practices.md @@ -0,0 +1,78 @@ +# Практики работы агента с движком и игрой + +**Живой документ**: каждая новая находка (грабля, удачный приём, экономящая +время последовательность) пополняет этот файл **в том же коммите**, где она +появилась. Структура — по ситуациям агента, не по подсистемам: нашёл свою +ситуацию → скопировал последовательность шагов. + +## Ситуация: проверяю своё изменение + +1. `npm run typecheck` — быстрый отсев. +2. `npx vitest run <затронутый тест>` — точечный прогон. +3. `node tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + + карты + карты-файлы равны генераторам + браузерная проверка инвариантов). +4. Смоук акта 1 (`node tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. + +## Ситуация: добавляю локацию + +1. Генератор в `tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. +2. `LocationId` + `LocationDef` в `data/locations.ts`: spawn/npcs/enemies/exits. +3. Экспорт через `AREAS`/`areaOf` и загрузка в `BootScene` по `Object.keys(AREAS)`. +4. Валидатор проверит спавн/врагов/exits автоматически (`agent:invariants`): + **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен + на воду/дерево). Чинить данные, а не валидатор. +5. Сценарий в `tools/checks/`: переход туда и обратно через `walkTo` + `waitFor`. + +## Ситуация: добавляю NPC/диалог + +1. `NpcDef` в `data/npcs.ts`, граф в `data/dialogues.ts`. +2. Валидатор проверит: несуществующие `next`/`choices` → `error`, недостижимые + узлы → `warn` (сироты допустимы, но проверь, что это не забытая ветка). +3. Проверка через мост: `walkTo` до соседнего тайла → `tapTile(NPC)` → + `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота. + +## Ситуация: упал смоук / проверка + +См. `agent.md` «waitFor и переходы сцен». Частые причины, по частоте: + +1. **Действие проглочено fade-переходом**: `begin()` молча отбрасывает + параллельный переход, сцена игнорирует клики при `transitioning`. Перед + действием — `waitFor('!s.transitioning')`. +2. **Клик по тайлу вне экрана**: в смоук-скриптах с реальными кликами цель + за краем канваса (виртуальный y > 270) клик мимо. Через мост не актуально + (`tapTile` в юнитах), но в старых скриптах — целься в промежуточный тайл. +3. **walkTo в занятый тайл** (NPC, высокий объект): тайл непроходим, маршрут + не найден. Кликни в сам NPC-тайл (`tapTile`) — сцена подведёт героя сама. +4. **Гонка rAF и инъекции**: инъекция и шаг должны быть в одном JS-стеке — + высокоуровневые методы моста (`tapTile/press/key/walkTo`) уже атомарны; + сырые `inject*` из страницы + отдельный `step` — гонка с rAF. +5. `s.error` в снапшоте — мост поймал исключение, читай текст. + +## Ситуация: меняю API движка + +1. Обнови соответствующий `docs/engine/*.md` и этот файл (если появился новый + приём) **в том же коммите**. +2. Экспорт только через `packages/engine/src/index.ts` — под-пути движка + импортировать нельзя. +3. `npm run typecheck` ловит разрывы в обоих пакетах; тесты движка — в Vitest + без браузера (Pixi-зависимости — через стабы, см. `GameLoop.manual.test.ts`). + +## Ситуация: пишу новый сценарий tools/checks/ + +1. Каркас: `import { startDevServer, openGame, Checks } from '../agent-lib.mjs'`; + `export default async function ({ pretty })`; вернуть `c.finish({pretty})`. +2. Проверка: возвращает `false` или кидает исключение при провале (строка — + это детали успеха!); удобнее `c.expect(cond, msg, details)`. +3. Ввод — через мост в юнитах (`walkTo`/`tapTile`/`press`), не через реальные + клики по CSS-пикселям. +4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов. + +## Грабли среды (кратко, подробности в CLAUDE.md) + +- `Assets.load` без `Assets.init()` висит навсегда — грузи только через `AssetLoader`. +- Headless Chromium: `--autoplay-policy=no-user-gesture-required` обязателен; + `ctx.resume()` без него не резолвится. +- Маяки консоли: `[boot] ассеты загружены` (BootScene), `[location] ` + (вход в локацию) — на них строится `openGame`. +- `window.__agent` только в DEV-сборке; для прод-сборки — `VITE_AGENT=1` (когда понадобится). +- Vite-алиас `@rpg/engine` — только regex; строковый перехватывает под-пути. \ No newline at end of file diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..cbeaad7 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,38 @@ +# Пепельные луга — изометрическая пиксельная RPG + +Собственный жанронезависимый движок (`packages/engine`, TypeScript, PixiJS 8 +только как рендер-бэкенд) и игра на нём (`apps/game`). Движок и инструментарий +делаются для работы ИИ-агента: детерминированный шаг, агентный мост +(`window.__agent`), runtime-валидация контента, CLI-проверки. + +Начни с `CLAUDE.md` в корне (команды, архитектура, грабли) и +`docs/engine/practices.md` (приёмы по ситуациям). Проверка любого изменения: +`node tools/agent.mjs check --pretty`. + +## Движок + +- [README движка](engine/README.md): архитектура и принципы, карта модулей. +- [Начало работы](engine/getting-started.md): Engine, GameLoop, сцены, первый запуск. +- [Ядро](engine/core.md): GameLoop, GameState, Settings, EventBus, Tween. +- [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, pixel-perfect масштаб. +- [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. +- [Карты](engine/maps.md): изометрия, A*, формат rpg-map, Tiled-импорт. +- [UI и диалоги](engine/ui-and-dialogue.md): PixelText, Panel, Button, MenuList, DialogueBox, DialogueRunner. +- [Кат-сцены](engine/cutscene.md): раннер кат-сцен. +- [Ассеты, аудио, сейвы](engine/assets-audio-save.md): AssetLoader, атласы, шины аудио, SaveManager. +- [Пайплайн арта](engine/art-pipeline.md): генератор пиксель-арта, палитра, атласы. +- [Рецепты](engine/recipes.md): короткие «как сделать X». +- [Агентный мост](engine/agent.md): EngineAgent, window.__agent, waitFor, валидация контента, tools/agent.mjs. + +## Инструменты агента + +- [Практики](engine/practices.md): живой документ приёмов по ситуациям агента; пополняется в каждом коммите. +- `tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). +- `tools/agent-lib.mjs`: openGame, AgentClient, Checks — каркас сценариев. +- `tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. + +## Мир и арт + +- [Библия мира](world.md): сеттинг, локации, персонажи, сюжет — сверять любой новый контент. +- [Арт-библия](art-style.md): палитра 32 цвета, размеры спрайтов, правила стиля. +- [Срез демо](demo.md): матрица «подсистема → где показана», статус, дорожная карта. \ No newline at end of file diff --git a/package.json b/package.json index 549cbc2..69c9441 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "maps": "vitest run tools/maps/gen.test.ts", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game" + "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game", + "agent": "node tools/agent.mjs dev", + "agent:check": "node tools/agent.mjs check", + "agent:snapshot": "node tools/agent.mjs snapshot --new-game" }, "devDependencies": { "typescript": "^5.6.0", diff --git a/packages/engine/src/agent/EngineAgent.ts b/packages/engine/src/agent/EngineAgent.ts new file mode 100644 index 0000000..6a489b4 --- /dev/null +++ b/packages/engine/src/agent/EngineAgent.ts @@ -0,0 +1,206 @@ +import type { + AgentHost, + EngineSnapshot, + Invariant, + SceneAgent, + SnapshotLayer, + WaitForOptions +} from './types'; +import { mergeInvariants } from './invariants'; + +/** + * Движковый агентный мост: снапшоты, детерминированные шаги, инъекция ввода. + * Никогда не бросает: каждый доступ — через safe() с try/catch, ошибка + * становится слоем снапшота `{ error }`. + */ +export class EngineAgent { + constructor( + private host: AgentHost, + /** Фабрика сцены-агента: сцена может отсутствовать (меню, переход). */ + private sceneAgent: () => SceneAgent | null + ) {} + + /** Полный снапшот: движковый слой + контентный слой текущей сцены. */ + snapshot(): SnapshotLayer { + const base = this.safe('snapshot', (): EngineSnapshot => { + const h = this.host; + return { + tick: h.tickCount, + fps: h.fixedStep > 0 ? Math.round((1 / h.fixedStep) * 100) / 100 : 0, + fixedStep: h.fixedStep, + scenes: [h.scenes.current?.constructor.name ?? null].filter( + (s): s is string => s !== null + ), + transitioning: h.scenes.transitioning, + camera: { + x: num(h.camera.x), + y: num(h.camera.y), + shaking: h.camera.shaking + }, + pointer: this.pointerLayer() + }; + }) as SnapshotLayer; + const scene = this.sceneAgent(); + if (scene) { + const layer = this.safe('scene.snapshot', () => scene.agentSnapshot()); + if (layer) Object.assign(base, layer); + } + return base; + } + + /** Нарушенные инварианты: движковые + сценические. */ + invariants(): Invariant[] { + const engine: Invariant[] = this.safe('invariants', () => { + const cam = this.host.camera; + return mergeInvariants( + checkNum('camera.x', cam.x, 'engine/camera'), + checkNum('camera.y', cam.y, 'engine/camera'), + this.pointerInvariants() + ); + }) ?? []; + const scene = this.sceneAgent(); + const sceneInvs = scene + ? (this.safe('scene.invariants', () => scene.agentInvariants()) ?? []) + : []; + return [...engine, ...(Array.isArray(sceneInvs) ? sceneInvs : [])]; + } + + /** n фиксированных шагов без реального ожидания (детерминированно). */ + step(n = 1, opts?: { render?: boolean }): { tick: number } { + const render = opts?.render ?? false; + // Серия целиком в ручном режиме: реальный rAF-цикл между шагами не + // тикает и не очищает «just pressed» инъекции середины серии. + try { + this.host.stepTicks(n, render); + } catch { + return { tick: this.host.tickCount }; + } + return { tick: this.host.tickCount }; + } + + /** + * Крутить шаги, пока предикат не станет истинным (или лимит шагов). + * pred вызывается после каждого шага на полном снапшоте. + */ + async waitFor( + pred: (s: SnapshotLayer) => boolean, + opts?: WaitForOptions + ): Promise<{ ok: boolean; snapshot: SnapshotLayer; ticks: number }> { + const limit = opts?.timeoutTicks ?? 600; + const render = opts?.render ?? false; + for (let i = 0; i < limit; i++) { + this.step(1, { render }); + let s: SnapshotLayer; + try { + s = this.snapshot(); + } catch { + continue; + } + let ok = false; + try { + ok = pred(s); + } catch { + ok = false; + } + if (ok) return { ok: true, snapshot: s, ticks: i + 1 }; + } + return { ok: false, snapshot: this.snapshot(), ticks: limit }; + } + + /** Ввод в виртуальных пикселях: эквивалент pointerdown/up на канвасе. */ + /** + * Клик в виртуальных пикселях. Инъекция и один шаг логики — атомарно + * (один JS-тик): между evaluate rAF-кадр успел бы очистить «just pressed». + */ + tapVirtual(vx: number, vy: number): void { + this.safe('tapVirtual', () => { + this.host.input.injectPointerDown(vx, vy); + this.host.input.injectPointerUp(vx, vy); + this.host.stepTick(); + }); + } + + /** Действие через маппинг действий (isActionJustPressed внутри этого же тика). */ + press(action: string, holdTicks = 1): void { + this.safe('press', () => { + this.host.input.injectAction(action); + this.host.stepTick(); // действие видно сцене в этом же тике + for (let i = 1; i < holdTicks; i++) this.host.stepTick(); + this.host.input.injectActionRelease(action); + }); + } + + /** Сырой код клавиши (e.code) — атомарно с одним шагом логики. */ + key(code: string): void { + this.safe('key', () => { + this.host.input.injectKeyCode(code); + this.host.stepTick(); + this.host.input.injectKeyCodeUp(code); + }); + } + + /** + * Реальный DOM-клик по канвасу (виртуальные px → CSS): нужен для Pixi-кнопок, + * если сцена слушает DOM напрямую. В геймплее предпочитай tapVirtual/tapTile. + */ + uiTap(vx: number, vy: number): void { + this.safe('uiTap', () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const sx = rect.left + (vx / this.host.virtualWidth) * rect.width; + const sy = rect.top + (vy / this.host.virtualHeight) * rect.height; + canvas.dispatchEvent(new MouseEvent('pointerdown', { + clientX: sx, clientY: sy, bubbles: true + })); + canvas.dispatchEvent(new MouseEvent('pointerup', { + clientX: sx, clientY: sy, bubbles: true + })); + }); + } + + /** Команда сцены (whitelist в реализации SceneAgent). */ + command(name: string, args?: unknown): unknown { + const scene = this.sceneAgent(); + if (!scene?.agentCommand) return null; + return this.safe('command', () => scene.agentCommand!(name, args as never)) ?? null; + } + + // --- внутреннее --- + + private pointerLayer(): SnapshotLayer { + const p = this.host.input.getPointer(); + return { + x: num(p.x), y: num(p.y), down: p.down, + justPressed: p.justPressed, downTicks: p.downTicks + }; + } + + private pointerInvariants(): Invariant[] { + const p = this.host.input.getPointer(); + return mergeInvariants( + checkNum('pointer.x', p.x, 'engine/pointer'), + checkNum('pointer.y', p.y, 'engine/pointer') + ); + } + + /** Выполнить fn, поймав исключение: ошибка -> слой { error }, а не бросок. */ + private safe(label: string, fn: () => T): T | null { + try { + return fn(); + } catch (e) { + return { error: `${label}: ${e instanceof Error ? e.message : String(e)}` } as unknown as T; + } + } +} + +function num(v: number): number { + return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : String(v) as unknown as number; +} + +function checkNum(name: string, v: number, where: string): Invariant | null { + if (!Number.isFinite(v)) { + return { id: 'pos-nan', severity: 'error', message: `${name} = ${v}`, where }; + } + return null; +} \ No newline at end of file diff --git a/packages/engine/src/agent/__tests__/EngineAgent.test.ts b/packages/engine/src/agent/__tests__/EngineAgent.test.ts new file mode 100644 index 0000000..272cd36 --- /dev/null +++ b/packages/engine/src/agent/__tests__/EngineAgent.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { Camera } from '../../render/Camera'; +import { EventBus } from '../../core/EventBus'; +import { EngineAgent } from '../EngineAgent'; +import type { AgentHost, SceneAgent, SnapshotLayer } from '../types'; + +/** + * Фейковый хост: без Pixi/канваса. stepTick двигает «время» и вызывает + * sceneAgent; renderFrame только помечается. + */ +function fakeHost(): AgentHost & { renders: number } { + let ticks = 0; + const events = new EventBus(); + return { + events, + scenes: { current: undefined, transitioning: false } as never, + input: { + getPointer: () => ({ x: 0, y: 0, down: false, justPressed: false, downTicks: 0 }) + } as never, + camera: new Camera(480, 270), + virtualWidth: 480, + virtualHeight: 270, + fixedStep: 1 / 60, + get tickCount() { + return ticks; + }, + stepTick() { + return ++ticks; + }, + stepTicks(n = 1) { + for (let i = 0; i < n; i++) this.stepTick(); + return ticks; + }, + renderFrame() { + this.renders++; + }, + renders: 0 + }; +} + +function fakeScene(): SceneAgent { + return { + agentSnapshot: () => ({ area: 'test', hp: 5 }), + agentInvariants: () => [{ id: 'warn-x', severity: 'warn', message: 'подозрительно' }], + agentCommand: (name) => (name === 'ping' ? 'pong' : null) + }; +} + +describe('EngineAgent — снапшот и шаги', () => { + it('снапшот содержит движковый слой + слой сцены', () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + const s = agent.snapshot() as SnapshotLayer; + expect(s.tick).toBe(0); + expect(s.fixedStep).toBeCloseTo(1 / 60, 6); + expect(s.transitioning).toBe(false); + expect(s.camera).toBeTruthy(); + expect(s.pointer).toBeTruthy(); + expect(s.area).toBe('test'); + expect(s.hp).toBe(5); + }); + + it('без сцены снапшот всё равно валиден', () => { + const agent = new EngineAgent(fakeHost(), () => null); + const s = agent.snapshot(); + expect(s.tick).toBe(0); + expect(s.area).toBeUndefined(); + }); +}); + +describe('EngineAgent — инварианты', () => { + it('мержит движковые и сценические инварианты', () => { + const host = fakeHost(); + host.camera.x = NaN; // движковый инвариант pos-nan + const agent = new EngineAgent(host, () => fakeScene()); + const invs = agent.invariants(); + expect(invs.map((i) => i.id)).toEqual(['pos-nan', 'warn-x']); + }); +}); + +describe('EngineAgent — шаги и команды', () => { + it('step крутит ровно n тиков', () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + expect(agent.step(10).tick).toBe(10); + expect(host.tickCount).toBe(10); + }); + + it('waitFor завершается по предикату и по лимиту', async () => { + const host = fakeHost(); + const agent = new EngineAgent(host, () => fakeScene()); + const ok = await agent.waitFor((s) => (s.tick as number) >= 5, { timeoutTicks: 100 }); + expect(ok.ok).toBe(true); + expect(ok.ticks).toBe(5); + const fail = await agent.waitFor(() => false, { timeoutTicks: 3 }); + expect(fail.ok).toBe(false); + expect(fail.ticks).toBe(3); + }); + + it('command проходит в сцену, неизвестная — null', () => { + const agent = new EngineAgent(fakeHost(), () => fakeScene()); + expect(agent.command('ping')).toBe('pong'); + expect(agent.command('unknown')).toBeNull(); + }); + + it('ошибка в сцене не вылетает, а попадает в слой { error }', () => { + const bad: SceneAgent = { + agentSnapshot: () => { + throw new Error('бум'); + }, + agentInvariants: () => { + throw new Error('бум'); + } + }; + const agent = new EngineAgent(fakeHost(), () => bad); + const s = agent.snapshot() as SnapshotLayer; + expect(String(s.error)).toContain('бум'); + expect(agent.invariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/agent/__tests__/invariants.test.ts b/packages/engine/src/agent/__tests__/invariants.test.ts new file mode 100644 index 0000000..12fa1d4 --- /dev/null +++ b/packages/engine/src/agent/__tests__/invariants.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + checkBounds, + checkFinite, + checkRange, + checkWalkable, + mergeInvariants +} from '../invariants'; + +const where = 'test'; + +describe('инварианты — хелперы', () => { + it('checkFinite находит NaN и Infinity', () => { + const invs = checkFinite({ x: 1.5, y: NaN, z: Infinity }, where); + expect(invs).toHaveLength(2); + expect(invs.every((i) => i.id === 'pos-nan' && i.severity === 'error')).toBe(true); + expect(checkFinite({ ok: 0 }, where)).toHaveLength(0); + }); + + it('checkRange ловит выход за диапазон и NaN', () => { + expect(checkRange('hp', 3, 0, 5, where)).toBeNull(); + expect(checkRange('hp', -1, 0, 5, where)?.id).toBe('range'); + expect(checkRange('hp', NaN, 0, 5, where)?.id).toBe('pos-nan'); + }); + + it('checkBounds и checkWalkable работают по тайлу', () => { + const map = { isWalkable: (x: number, y: number) => x + y > 0 }; + expect(checkBounds('герой', { x: 2, y: 3 }, 10, 10, where)).toBeNull(); + expect(checkBounds('герой', { x: -1, y: 3 }, 10, 10, where)?.id).toBe('out-of-bounds'); + expect(checkWalkable('герой', { x: 1, y: 1 }, map, where)).toBeNull(); + expect(checkWalkable('герой', { x: 0, y: 0 }, map, where)?.id).toBe('in-wall'); + }); + + it('mergeInvariants разворачивает группы и отбрасывает пустое', () => { + const a = { id: 'a', severity: 'warn', message: 'a' } as const; + const b = { id: 'b', severity: 'error', message: 'b' } as const; + expect(mergeInvariants(null, false, undefined, a, [b as never], null)).toEqual([a, b]); + expect(mergeInvariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/agent/invariants.ts b/packages/engine/src/agent/invariants.ts new file mode 100644 index 0000000..c463ccf --- /dev/null +++ b/packages/engine/src/agent/invariants.ts @@ -0,0 +1,79 @@ +import type { Invariant } from './types'; + +/** + * Чистые хелперы инвариантов: сцены и системы собирают нарушения через них, + * мост мержит и отдаёт списком. Без зависимостей — тестируется в Vitest. + */ + +function inv(id: string, severity: 'error' | 'warn', message: string, where?: string): Invariant { + return where ? { id, severity, message, where } : { id, severity, message }; +} + +/** NaN/Infinity в числовых значениях (позиции, hp и т.п.) — почти всегда сломанный мир. */ +export function checkFinite(values: Record, where: string): Invariant[] { + const out: Invariant[] = []; + for (const [name, v] of Object.entries(values)) { + if (!Number.isFinite(v)) { + out.push(inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where)); + } + } + return out; +} + +/** Число вне диапазона (hp < 0, скорость < 0, ...). */ +export function checkRange( + name: string, + v: number, + min: number, + max: number, + where: string +): Invariant | null { + if (!Number.isFinite(v)) { + return inv('pos-nan', 'error', `${name} = ${v} (не конечное число)`, where); + } + if (v < min || v > max) { + return inv('range', 'error', `${name} = ${v} вне [${min}, ${max}]`, where); + } + return null; +} + +/** Тайл в границах карты. */ +export function checkBounds( + name: string, + tile: { x: number; y: number }, + w: number, + h: number, + where: string +): Invariant | null { + if (!Number.isInteger(tile.x) || !Number.isInteger(tile.y) || tile.x < 0 || tile.y < 0 + || tile.x >= w || tile.y >= h) { + return inv('out-of-bounds', 'error', `${name}: тайл (${tile.x},${tile.y}) вне карты ${w}×${h}`, where); + } + return null; +} + +/** Тайл проходим (герой/NPC не должны стоять в стене). */ +export function checkWalkable( + name: string, + tile: { x: number; y: number }, + map: { isWalkable(x: number, y: number): boolean }, + where: string +): Invariant | null { + if (!map.isWalkable(tile.x, tile.y)) { + return inv('in-wall', 'error', `${name} стоит в непроходимом тайле (${tile.x},${tile.y})`, where); + } + return null; +} + +/** Мерж групп: null/false/undefined отбрасываются, группы — разворачиваются. */ +export function mergeInvariants( + ...groups: (Invariant | null | undefined | false | Invariant[])[] +): Invariant[] { + const out: Invariant[] = []; + for (const g of groups) { + if (!g) continue; + if (Array.isArray(g)) out.push(...g); + else out.push(g); + } + return out; +} \ No newline at end of file diff --git a/packages/engine/src/agent/types.ts b/packages/engine/src/agent/types.ts new file mode 100644 index 0000000..69ead7c --- /dev/null +++ b/packages/engine/src/agent/types.ts @@ -0,0 +1,74 @@ +import type { Camera } from '../render/Camera'; +import type { EventBus } from '../core/EventBus'; +import type { InputManager } from '../input/InputManager'; +import type { SceneManager } from '../scene/SceneManager'; + +/** + * Агентный мост (движковый каркас): машиночитаемый доступ к игре — + * снапшоты состояния, детерминированные шаги, инъекция ввода, инварианты. + * Игра строит контентный слой поверх каркаса (см. docs/engine/agent.md). + * Всё, что делает мост, — никогда не бросает исключений: ошибка превращается + * в слой снапшота `{ error: string }`, а не в потерю телеметрии. + */ + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; +export type SnapshotLayer = { [k: string]: JsonValue }; + +/** Нарушенный инвариант: error — «мир сломан», warn — «подозрительно». */ +export interface Invariant { + /** Стабильный id: 'pos-nan', 'hero-in-wall', ... */ + id: string; + severity: 'error' | 'warn'; + message: string; + /** Где обнаружено: 'scene/LocationScene', 'engine/camera', ... */ + where?: string; +} + +/** + * Что сцена отдаёт мосту. Реализуется игровыми сценами; движок вызывает + * методы через фабрику `() => SceneAgent | null` (сцена может быть не готова). + */ +export interface SceneAgent { + /** Контентный слой снапшота этой сцены. */ + agentSnapshot(): SnapshotLayer; + /** Инварианты сцены (мост мержит со своими и движковыми). */ + agentInvariants(): Invariant[]; + /** Произвольные команды (перемотки/читы для проверок). null — команда неизвестна. */ + agentCommand?(name: string, args?: JsonValue): JsonValue; +} + +/** Минимальный хост, который нужен мосту (удовлетворяется Engine). */ +export interface AgentHost { + readonly scenes: SceneManager; + readonly input: InputManager; + readonly camera: Camera; + readonly events: EventBus; + /** Виртуальное разрешение (для пересчёта в CSS-пиксели в uiTap). */ + readonly virtualWidth: number; + readonly virtualHeight: number; + /** Один фиксированный шаг (без рендера); возвращает счётчик тиков. */ + stepTick(): number; + /** Серия шагов в ручном режиме (setManual — без «догоняния» rAF). */ + stepTicks(n?: number, render?: boolean): number; + renderFrame(): void; + readonly tickCount: number; + readonly fixedStep: number; +} + +/** Движковый слой снапшота. */ +export interface EngineSnapshot extends SnapshotLayer { + tick: number; + fps: number; + fixedStep: number; + scenes: string[]; + transitioning: boolean; + camera: SnapshotLayer; + pointer: SnapshotLayer; +} + +export interface WaitForOptions { + /** Лимит шагов (по умолчанию 600 — 10 с игрового времени). */ + timeoutTicks?: number; + /** Рендерить ли каждый шаг (нужно только перед скриншотом). */ + render?: boolean; +} \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index 27a111e..593c4d6 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -29,6 +29,7 @@ input!: InputManager; private loop: GameLoop; private started = false; + private ticks = 0; private readonly autoResize: boolean; private onWindowResize = (): void => { this.renderer.resize( @@ -87,6 +88,52 @@ this.events.clear(); } + /** Счётчик фиксированных шагов с запуска (для агента и отладки). */ + get tickCount(): number { + return this.ticks; + } + + /** Длительность фиксированного шага, сек. */ + get fixedStep(): number { + return this.loop.step; + } + + /** Виртуальное разрешение (для пересчёта виртуальных px в CSS). */ + get virtualWidth(): number { + return this.renderer.virtualWidth; + } + + get virtualHeight(): number { + return this.renderer.virtualHeight; + } + + /** Один фиксированный шаг (без рендера) — мост/тесты крутят шаги сами. */ + stepTick(): number { + this.tick(this.loop.step); + this.ticks++; + this.events.emit('engine:tick', { tick: this.ticks }); + return this.ticks; + } + + /** Один кадр рендера без шага логики. */ + renderFrame(): void { + this.render(); + } + + /** + * n фиксированных шагов детерминированно: цикл на время переводится в ручной + * режим, после — тайминги сбрасываются, чтобы rAF не «догонял» пропущенное. + */ + stepTicks(n = 1, render = false): number { + this.loop.setManual(true); + for (let i = 0; i < n; i++) { + this.stepTick(); + if (render) this.render(); + } + this.loop.setManual(false); + return this.ticks; + } + /** Один фиксированный шаг. */ private tick(dt: number): void { this.input?.update(); // опрос геймпадов до логики diff --git a/packages/engine/src/core/GameLoop.ts b/packages/engine/src/core/GameLoop.ts index f76d506..7a4d11d 100644 --- a/packages/engine/src/core/GameLoop.ts +++ b/packages/engine/src/core/GameLoop.ts @@ -20,6 +20,8 @@ private lastTime = 0; private accumulator = 0; private running = false; + /** Ручной режим: rAF не планирует шаги (их крутит мост/тесты через stepTick). */ + private manual = false; constructor(fps: number, callbacks: LoopCallbacks) { this.step = 1 / fps; @@ -38,8 +40,31 @@ cancelAnimationFrame(this.rafId); } + /** Ручной режим: кадры rAF не делают ни шагов, ни рендера. */ + setManual(on: boolean): void { + this.manual = on; + if (on) this.resetTiming(); + } + + /** + * Сброс таймингов: аккумулятор и lastTime обнуляются, чтобы следующий + * реальный кадр не «догонял» пропущенное время (до 5 лишних тиков). + */ + resetTiming(): void { + this.accumulator = 0; + this.lastTime = performance.now(); + } + private tick = (now: number): void => { if (!this.running) return; + if (this.manual) { + // Ручной режим: кадр пустой, но rAF и тайминги живут — иначе при + // выходе из ручного режима цикл «догонит» пропущенное время. + this.rafId = requestAnimationFrame(this.tick); + this.accumulator = 0; + this.lastTime = now; + return; + } this.rafId = requestAnimationFrame(this.tick); this.accumulator += Math.min((now - this.lastTime) / 1000, 0.25); diff --git a/packages/engine/src/core/__tests__/GameLoop.manual.test.ts b/packages/engine/src/core/__tests__/GameLoop.manual.test.ts new file mode 100644 index 0000000..563e036 --- /dev/null +++ b/packages/engine/src/core/__tests__/GameLoop.manual.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GameLoop } from '../GameLoop'; + +/** Заглушки rAF: frame() продвигает кадры вручную. */ +function installRaf(): (advanceMs: number) => void { + let now = 0; + const callbacks: ((t: number) => void)[] = []; + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + callbacks.push(cb); + return callbacks.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => undefined); + vi.stubGlobal('performance', { now: () => now }); + return (advanceMs: number) => { + now += advanceMs; + const list = [...callbacks]; + callbacks.length = 0; + for (const cb of list) cb(now); + }; +} + +describe('GameLoop — ручной режим', () => { + it('setManual(true) гасит кадры, resetTiming не даёт догонять', () => { + const frame = installRaf(); + const updates: number[] = []; + const loop = new GameLoop(60, { + update: (dt) => updates.push(dt), + render: () => undefined + }); + loop.start(); + frame(1000); // ~60 шагов сгущаются в максимум 5 + expect(updates.length).toBe(5); + + loop.setManual(true); + frame(1000); + expect(updates.length).toBe(5); // кадры в ручном режиме пустые + + loop.setManual(false); + frame(20); // resetTiming в setManual(true) обнулил аккумулятор — без «догоняния» + expect(updates.length).toBe(6); // ровно один шаг, а не 5+5 + loop.stop(); + vi.unstubAllGlobals(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts index 4c72cff..e39ac19 100644 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -72,7 +72,7 @@ private view: DialogueView | null = null; private shownChoices: { index: number; text: string }[] = []; private pendingNext: string | undefined; - private waitingForChoice = false; + private awaitingChoice = false; constructor( private state: GameState, @@ -90,10 +90,32 @@ return this.graph !== null; } + // --- для агентного моста/отладки: раскрытие текущего состояния --- + + /** id текущего узла (или null, если диалог не активен/завершается). */ + get nodeId(): string | null { + return this.currentId; + } + + /** Текущий узел (для снапшота моста). */ + get node(): DialogueNode | null { + return this.currentNode; + } + + /** Показанные варианты выбора (индексы — в узел графа). */ + get choices(): { index: number; text: string }[] { + return this.shownChoices; + } + + /** Ожидается ли выбор варианта. */ + get waitingForChoice(): boolean { + return this.awaitingChoice; + } + /** Запустить диалог. */ start(graph: DialogueGraph): void { this.graph = graph; - this.waitingForChoice = false; + this.awaitingChoice = false; this.pendingNext = undefined; this.enterNode(graph.start); } @@ -102,7 +124,7 @@ * «Дальше»: показывает следующий узел. Во время выбора варианта игнорируется. */ advance(): void { - if (!this.graph || this.waitingForChoice) return; + if (!this.graph || this.awaitingChoice) return; const next = this.pendingNext; if (next === undefined) { this.finish(); @@ -113,13 +135,13 @@ /** Выбрать вариант ответа. */ pick(index: number): void { - if (!this.graph || !this.waitingForChoice) return; + if (!this.graph || !this.awaitingChoice) return; const shown = this.shownChoices[index]; if (!shown) return; const choice = this.currentNode?.choices?.[shown.index]; if (!choice) return; - this.waitingForChoice = false; + this.awaitingChoice = false; this.shownChoices = []; this.applyEffects(choice); if (choice.next === undefined) { @@ -133,7 +155,7 @@ finish(): void { const graph = this.graph; this.graph = null; - this.waitingForChoice = false; + this.awaitingChoice = false; this.shownChoices = []; this.pendingNext = undefined; this.view?.hide(); @@ -236,7 +258,7 @@ textlessChoice = false ): void { this.shownChoices = choices; - this.waitingForChoice = choices.length > 0; + this.awaitingChoice = choices.length > 0; this.view?.show({ speaker: n.speaker, text: textlessChoice ? '' : (n.text ?? ''), diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b9df9d4..5294182 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -20,6 +20,25 @@ type EaseFn } from './core/easing'; +// agent (инструменты ИИ-агента: снапшоты, шаги, ввод, инварианты) +export { EngineAgent } from './agent/EngineAgent'; +export { + type AgentHost, + type EngineSnapshot, + type Invariant, + type SceneAgent, + type SnapshotLayer, + type JsonValue, + type WaitForOptions +} from './agent/types'; +export { + checkFinite, + checkRange, + checkBounds, + checkWalkable, + mergeInvariants +} from './agent/invariants'; + // state (см. core) export { GameState, type GameStateData } from './core/GameState'; export { StateMachine, type StateDef } from './core/StateMachine'; diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index dae25e6..921e62c 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -216,6 +216,63 @@ this.canvas.removeEventListener('pointermove', this.onPointerMove); } + // --- инъекция ввода (агентный мост / тесты) --- + // Идёт через ТЕ ЖЕ структуры, что и реальные события: инъекция между тиками + // семантически идентична нажатию, «just pressed» виден ровно один тик. + + /** pointerdown в виртуальных пикселях (без DOM). */ + injectPointerDown(x: number, y: number, isTouch = false): void { + this.pointer.x = x; + this.pointer.y = y; + this.pointer.down = true; + this.pointer.justPressed = true; + this.pointer.downTicks = 0; + this.pointer.isTouch = isTouch; + this.handlers.onPointerDown?.(this.pointer); + } + + /** pointerup в виртуальных пикселях (без DOM). */ + injectPointerUp(x: number, y: number, isTouch = false): void { + this.pointer.x = x; + this.pointer.y = y; + this.pointer.down = false; + this.pointer.justReleased = true; + this.pointer.isTouch = isTouch; + this.handlers.onPointerUp?.(this.pointer); + } + + /** Действие «нажато» (isActionJustPressed на следующем тике). */ + injectAction(action: string): void { + for (const [code, actions] of this.keyActions) { + if (actions.includes(action)) { + if (!this.keysDown.has(code)) this.keysPressed.add(code); + this.keysDown.add(code); + } + } + } + + /** Действие «отпущено». */ + injectActionRelease(action: string): void { + for (const [code, actions] of this.keyActions) { + if (actions.includes(action)) { + this.keysDown.delete(code); + this.keysReleased.add(code); + } + } + } + + /** Сырой код клавиши (e.code) — нажать и отпустить нельзя по отдельности: см. injectKeyCodeUp. */ + injectKeyCode(code: string): void { + if (!this.keysDown.has(code)) this.keysPressed.add(code); + this.keysDown.add(code); + } + + /** Сырой код клавиши (e.code) — отпустить. */ + injectKeyCodeUp(code: string): void { + this.keysDown.delete(code); + this.keysReleased.add(code); + } + private onKeyDown = (e: KeyboardEvent): void => { if (!this.keysDown.has(e.code)) { this.keysPressed.add(e.code); diff --git a/packages/engine/src/input/__tests__/InputManager.test.ts b/packages/engine/src/input/__tests__/InputManager.test.ts index bfe8a5c..d8eae25 100644 --- a/packages/engine/src/input/__tests__/InputManager.test.ts +++ b/packages/engine/src/input/__tests__/InputManager.test.ts @@ -117,4 +117,38 @@ pd(pointerEvent(0, 0, 'touch')); expect(input.getPointer().isTouch).toBe(true); }); + + // --- инъекция ввода (агентный мост): та же семантика, что у реальных событий --- + + it('injectPointerDown/Up видны как justPressed ровно один тик', () => { + input.injectPointerDown(96, 54); + const p = input.getPointer(); + expect(p.down).toBe(true); + expect(p.justPressed).toBe(true); + expect(p.x).toBe(96); + input.endTick(); + expect(input.getPointer().justPressed).toBe(false); + input.injectPointerUp(96, 54); + expect(input.getPointer().justReleased).toBe(true); + }); + + it('injectAction срабатывает в isActionJustPressed один тик', () => { + input.injectAction('attack'); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + input.endTick(); + expect(input.isActionJustPressed('attack')).toBe(false); + expect(input.isActionActive('attack')).toBe(true); // всё ещё зажато + input.injectActionRelease('attack'); + expect(input.isActionActive('attack')).toBe(false); + expect(input.isActionJustReleased('attack')).toBe(true); + }); + + it('injectKeyCode виден в wasKeyPressed/в действие-маппинге', () => { + input.injectKeyCode('Space'); + expect(input.wasKeyPressed('Space')).toBe(true); + expect(input.isActionJustPressed('attack')).toBe(true); + input.injectKeyCodeUp('Space'); + expect(input.isKeyDown('Space')).toBe(false); + }); }); \ No newline at end of file diff --git a/tools/agent-lib.mjs b/tools/agent-lib.mjs new file mode 100644 index 0000000..bd80af2 --- /dev/null +++ b/tools/agent-lib.mjs @@ -0,0 +1,161 @@ +/** + * Библиотека для агентных проверок игры (puppeteer-core + системный Chromium). + * Единственное место с браузерным бойлерплейтом: смоуки и сценарии + * tools/checks/*.mjs строятся поверх openGame()/AgentClient. + */ +import puppeteer from 'puppeteer-core'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +export const CHROMIUM = '/usr/bin/chromium'; +export const BEACON = '[boot] ассеты загружены'; + +/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */ +export async function launchBrowser() { + return puppeteer.launch({ + executablePath: CHROMIUM, + headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] + }); +} + +/** Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. */ +export async function startDevServer({ port = 5199 } = {}) { + const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { + cwd: new URL('..', import.meta.url).pathname, + stdio: ['ignore', 'pipe', 'pipe'] + }); + const url = `http://localhost:${port}/`; + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) return { url, stop: () => proc.kill('SIGTERM') }; + } catch { /* ещё не поднялся */ } + await sleep(250); + } + proc.kill('SIGTERM'); + throw new Error(`dev-сервер на :${port} не поднялся за 30 с`); +} + +/** + * Открыть игру и дождаться готовности агента. + * newGame=true — кликнуть «Новая игра» через мост. + * Возвращает {browser, page, agent}. + */ +export async function openGame({ url = 'http://localhost:5199/', newGame = true } = {}) { + const browser = await launchBrowser(); + const page = await browser.newPage(); + await page.setViewport({ width: 960, height: 540 }); + const consoleLogs = []; + page.on('console', (m) => consoleLogs.push(m.text())); + page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`)); + + const booted = new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`маяк «${BEACON}» не пришёл за 30 с`)), 30000); + page.on('console', (m) => { if (m.text().includes(BEACON)) { clearTimeout(t); resolve(); } }); + }); + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + await booted; + // Дождаться конца fade-перехода BootScene->Menu: begin() молча отбрасывает + // команды, пришедшие во время перехода. + await sleep(1500); + + const agent = new AgentClient(page); + if (newGame) { + await agent.newGame(); + // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца. + await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + } + return { browser, page, agent, consoleLogs }; +} + +/** + * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate. + * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred. + */ +export class AgentClient { + constructor(page) { this.page = page; } + + async call(method, ...args) { + return this.page.evaluate((m, a) => { + const api = window.__agent; + if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' }; + return api[m](...a); + }, method, args); + } + + snapshot() { return this.call('snapshot'); } + invariants() { return this.call('invariants'); } + step(n = 1, opts) { return this.call('step', n, opts); } + waitFor(pred, opts) { return this.call('waitFor', pred, opts); } + tapTile(x, y) { return this.call('tapTile', x, y); } + tapVirtual(x, y) { return this.call('tapVirtual', x, y); } + press(action, holdTicks) { return this.call('press', action, holdTicks); } + key(code) { return this.call('key', code); } + command(name, args) { return this.call('command', name, args); } + walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } + runDialogue() { return this.call('runDialogue'); } + newGame() { return this.call('newGame'); } + currentArea() { return this.call('currentArea'); } + + async screenshot(path) { return this.page.screenshot({ path }); } +} + +/** + * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код. + * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish(); + */ +export class Checks { + constructor(name) { + this.name = name; + this.results = []; + this.started = Date.now(); + } + /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */ + async run(checkName, fn) { + const t0 = Date.now(); + try { + const details = await fn(); + this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0, + details: details === true ? null : details ?? null }); + } catch (err) { + this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }); + } + return this; + } + /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */ + expect(cond, msg, details) { + if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : '')); + return details; + } + /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */ + finish({ pretty = false } = {}) { + const report = { + name: this.name, + ok: this.results.every((r) => r.ok), + ms: Date.now() - this.started, + results: this.results + }; + console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report; + } +} + +/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */ +export function parseArgs(argv = process.argv.slice(2)) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty'; + else if (a === '--port') out.port = Number(argv[++i]); + else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean); + else if (a === '--new-game') out.newGame = true; + else if (a === '--out') out.out = argv[++i]; + else if (a === '--steps') out.steps = Number(argv[++i]); + else out._.push(a); + } + return out; +} \ No newline at end of file diff --git a/tools/agent.mjs b/tools/agent.mjs new file mode 100644 index 0000000..60a6cf2 --- /dev/null +++ b/tools/agent.mjs @@ -0,0 +1,156 @@ +/** + * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON + * (машинночитаемый), --pretty — для человека. + * + * node tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] + * node tools/agent.mjs run [--pretty] + * node tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] + * node tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty] + * node tools/agent.mjs dev [--port N] + */ +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs'; + +const args = parseArgs(); +const cmd = args._[0] ?? 'check'; + +/** Один «серверный» шаг проверки: результат + ms + детали. */ +function stepResult(name, fn) { + const t0 = Date.now(); + try { + const details = fn(); + return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; + } catch (err) { + return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; + } +} + +/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */ +const mapsFresh = () => stepResult('maps:fresh', () => { + const gen = spawnSync('npx', ['vitest', 'run', 'tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); + if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный'); + return 'файлы карт равны генераторам'; +}); + +/** Браузерная проверка: новая игра -> N шагов -> инварианты пусты (error). */ +async function agentInvariants() { + const c = new Checks('agent:invariants'); + const server = await startDevServer({ port: args.port ?? 5199 }); + let ctx; + try { + await c.run('новая игра + 300 шагов', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.step(300); + const inv = await ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + if (errs.length) return errs; + return 'инварианты чисты'; + }); + await c.run('снапшот валиден', async () => { + const s = await ctx.agent.snapshot(); + if (!s.hero || !Array.isArray(s.flags)) return 'снапшот без hero/flags'; + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty: args.pretty }); +} + +const commands = { + /** Полный прогон проверок. */ + async check() { + const all = [ + { name: 'typecheck', run: () => stepResult('typecheck', () => { + const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n')); + return null; + }) }, + { name: 'tests', run: () => stepResult('tests', () => { + const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные'); + return null; + }) }, + { name: 'maps', run: () => stepResult('maps', () => { + const r = spawnSync('npm', ['run', 'maps'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); + if (r.status !== 0) throw new Error('npm run maps не зелёный'); + return null; + }) }, + { name: 'maps:fresh', run: mapsFresh }, + { name: 'agent:invariants', run: agentInvariants } + ]; + const only = args.only ?? all.map((p) => p.name); + const skip = args.skip ?? []; + const report = { name: 'check', ok: true, ms: 0, results: [] }; + const t0 = Date.now(); + for (const probe of all) { + if (!only.includes(probe.name) || skip.includes(probe.name)) continue; + let res; + try { + res = await probe.run(); + } catch (err) { + res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) }; + } + report.results.push(res); + report.ok = report.ok && res.ok; + } + report.ms = Date.now() - t0; + console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report.ok ? 0 : 1; + }, + + /** Один сценарий из tools/checks/. */ + async run() { + const file = args._[1]; + if (!file) throw new Error('укажи путь к сценарию: node tools/agent.mjs run tools/checks/xxx.mjs'); + const mod = await import(pathToFileURL(file).href); + if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`); + return mod.default({ args, pretty: args.pretty }); + }, + + /** Снимок снапшота в файл/stdout. */ + async snapshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false }); + const snap = await ctx.agent.snapshot(); + if (args.steps) await ctx.agent.step(args.steps); + if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2)); + console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2)); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */ + async screenshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true }); + if (args.steps) await ctx.agent.step(args.steps); + const out = args.out ?? '/tmp/rpg_agent.png'; + await ctx.agent.screenshot(out); + console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) })); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */ + async dev() { + const server = await startDevServer({ port: args.port ?? 5199 }); + console.log(`dev: ${server.url}`); + process.on('SIGINT', () => { server.stop(); process.exit(0); }); + await new Promise(() => {}); + } +}; + +const fn = commands[cmd]; +if (!fn) { + console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`); + process.exit(2); +} +process.exit(await fn()); \ No newline at end of file diff --git a/tools/checks/agent-invariants.mjs b/tools/checks/agent-invariants.mjs new file mode 100644 index 0000000..f421d3a --- /dev/null +++ b/tools/checks/agent-invariants.mjs @@ -0,0 +1,38 @@ +/** + * Сценарий agent:invariants — мост жив, мир стабилен. + * Новая игра -> 300 фиксированных шагов -> движковые/сценические/контентные + * инварианты не содержат ошибок. + * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('agent-invariants'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра через мост', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + const s = await ctx.agent.snapshot(); + c.expect(s.scene === 'location', `сцена не location`, { scene: s.scene, err: s.error }); + return null; + }); + await c.run('300 шагов без ошибок инвариантов', async () => { + await ctx.agent.step(300); + const inv = await ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты нарушены', errs); + return null; + }); + await c.run('снапшот содержит героя и флаги', async () => { + const s = await ctx.agent.snapshot(); + c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); + c.expect(Array.isArray(s.flags), 'нет s.flags'); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/tools/maps/gen.test.ts b/tools/maps/gen.test.ts index 1747767..06e6b61 100644 --- a/tools/maps/gen.test.ts +++ b/tools/maps/gen.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -22,6 +22,16 @@ zvenets: buildZvenetsMap() }; +describe('карты-файлы на диске равны генераторам (fresh)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}.map не разъехался с генератором (npm run maps лечит)`, () => { + const path = resolve(outDir, `${id}.map`); + const onDisk = JSON.parse(readFileSync(path, 'utf8')); + expect(onDisk).toEqual(encodeMap(data)); + }); + } +}); + describe('генерация карт-файлов (rpg-map, RLE)', () => { for (const [id, data] of Object.entries(MAPS)) { it(`${id}: encode -> файл -> parse восстанавливает карту`, () => { diff --git a/tools/smoke-act1.mjs b/tools/smoke-act1.mjs index 04ae281..9010aec 100644 --- a/tools/smoke-act1.mjs +++ b/tools/smoke-act1.mjs @@ -1,158 +1,53 @@ /** - * Полный прогон акта 1: луга -> Звенец -> квест у Ирвина -> (сбор цветов - * эмулируется var'ом) -> сдача -> кат-сцена с колоколом. + * Полный прогон акта 1 через агентный мост: луга -> Звенец -> квест у Ирвина -> + * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. * Запуск: node tools/smoke-act1.mjs [url] [скриншот] */ -import puppeteer from 'puppeteer-core'; +import { openGame } from './agent-lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, - // и навигация, завязанная на звук, зависает. - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); - -page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); +const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[boot] ассеты загружены')) resolve(); - }); -}); - -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await booted; -await new Promise((r) => setTimeout(r, 1500)); - -// «Новая игра» -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); - -// Усыпить сгустков на лугах: путь героя не должен прерываться уроном. -await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); -}); - -// Клик в тайл (tx,ty) текущей локации. -async function clickTile(tx, ty) { - const p = await page.evaluate((tx, ty) => { +// Усыпить сгустков: путь героя не должен прерываться уроном. +async function sleepAll() { + await page.evaluate(() => { const g = window.__game; - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - const wx = (tx - ty) * 16; - const wy = (tx + ty) * 8 + 8; - return { x: rect.left + ((wx + wr.x) / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height }; - }, tx, ty); - await page.mouse.click(p.x, p.y); + for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); + }); } - -// Пошагово к цели: кликаем в соседний тайл от героя по направлению к цели — -// он всегда на экране (цель может уходить за нижний край канваса). -async function walkTo(tx, ty) { - for (let i = 0; i < 60; i++) { - const at = await page.evaluate((tx, ty) => { - const t = window.__game.scenes.current?.player?.currentTile(); - return t && t.x === tx && t.y === ty; - }, tx, ty); - if (at) return true; - const step = await page.evaluate((tx, ty) => { - const g = window.__game; - const t = g.scenes.current?.player?.currentTile(); - if (!t) return null; - // шаг по доминирующей оси к цели - const nx = Math.abs(tx - t.x) >= Math.abs(ty - t.y) - ? t.x + Math.sign(tx - t.x) : t.x; - const ny = nx === t.x ? t.y + Math.sign(ty - t.y) : t.y; - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - const wx = (nx - ny) * 16; - const wy = (nx + ny) * 8 + 8; - return { x: rect.left + ((wx + wr.x) / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height, nx, ny }; - }, tx, ty); - if (!step) return false; - if (i % 10 === 9) { - const t = await page.evaluate(() => { - const sc = window.__game.scenes.current; - return { tile: sc.player?.currentTile(), hp: sc.playerCombat?.hp }; - }); - console.log(` ...к (${tx},${ty}): герой в`, JSON.stringify(t)); - } - await page.mouse.click(step.x, step.y); - await new Promise((r) => setTimeout(r, 700)); - } - return false; -} +await sleepAll(); // 1) Луга -> тропа в Звенец (26,14) — триггер срабатывает на самом тайле. console.log('шаг 1: луга -> Звенец'); -await walkTo(26, 14); -await new Promise((r) => setTimeout(r, 1500)); -// на всякий случай снова усыпить (переход мог вернуть героя в бой) -await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); -}); +if (!(await agent.walkTo(26, 14))) console.log(' ! герой не дошёл до (26,14)'); +await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); +// Фаза «in» fade-перехода: сцена ещё игнорирует клики. +await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); +await sleepAll(); // переход мог вернуть героя в бой -// 2) Звенец: к Ирвину (12,9). -console.log('шаг 2: к Ирвину'); -await walkTo(11, 9); -await new Promise((r) => setTimeout(r, 500)); - -// Листаем диалог до конца: ждём, пока он откроется (герой мог идти к NPC), -// затем жмём Space, пока не закроется. Лишние клики по миру сбрасывают -// отложенный разговор — поэтому именно Space, а не клики. -async function runDialogue() { - let open = false; - for (let i = 0; i < 25; i++) { - if (await page.evaluate(() => window.__game.scenes.current?.dialogue?.active === true)) { - open = true; - break; - } - await new Promise((r) => setTimeout(r, 200)); - } - if (!open) { console.log(' ! диалог не открылся'); return; } - for (let i = 0; i < 12; i++) { - if (!(await page.evaluate(() => window.__game.scenes.current?.dialogue?.active === true))) break; - await page.keyboard.press('Space'); - await new Promise((r) => setTimeout(r, 350)); - } -} - -// 3) Диалог с Ирвином: квест «Три цветка». -console.log('шаг 3: квест у Ирвина'); -await clickTile(12, 9); -await runDialogue(); +// 2-3) Клик по Ирвину (12,9): сцена сама подведёт героя и откроет диалог. +console.log('шаг 2: квест у Ирвина'); +await agent.tapTile(12, 9); +if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); // 4) Сбор цветов — эмуляция var'ом (вылазка проверяется smoke-ponds). console.log('шаг 4: цветы собраны (эмуляция)'); -await page.evaluate(() => { - const g = window.__game; - g.state.setVar('flowers', 3); -}); +await agent.command('scene:setVar', { id: 'flowers', value: 3 }); // 5) Сдача Ирвину -> кат-сцена. console.log('шаг 5: сдача -> кат-сцена'); -await clickTile(12, 9); -await runDialogue(); +await agent.tapTile(12, 9); +if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); -// 6) Даём кат-сцене (~4с) доиграть до финального тоста. -await new Promise((r) => setTimeout(r, 5000)); -const state = await page.evaluate(() => { - const g = window.__game; - return { vars: g.state.serialize().vars, flags: g.state.allFlags }; -}); -console.log('итог:', JSON.stringify(state)); +// 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). +await agent.waitFor('s.cutscene == null || !s.cutscene.active', { timeoutTicks: 1200 }); + +const snap = await agent.snapshot(); +console.log('итог:', JSON.stringify({ flags: snap.flags, vars: snap.vars })); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); +if (!snap.flags.includes('quest_bells_done')) console.log(' ! квест не закрыт (quest_bells_done)'); await browser.close(); \ No newline at end of file diff --git a/tools/smoke-ponds.mjs b/tools/smoke-ponds.mjs index 8077667..91e4e22 100644 --- a/tools/smoke-ponds.mjs +++ b/tools/smoke-ponds.mjs @@ -1,87 +1,33 @@ /** - * Смоук перехода луга -> Серые пруды: герой идёт кликами в северо-западный - * угол карты, где триггер (2,2). Запуск: node tools/smoke-ponds.mjs [url] [скриншот] + * Смоук перехода луга -> Серые пруды: герой идёт через мост к триггеру (2,2). + * Запуск: node tools/smoke-ponds.mjs [url] [скриншот] */ -import puppeteer from 'puppeteer-core'; +import { openGame } from './agent-lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_ponds.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, - // и навигация, завязанная на звук, зависает. - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); +const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); -page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); - -// Ждём маяк BootScene: без него клики уходят «в загрузку». -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[boot] ассеты загружены')) resolve(); - }); +// Усыпить сгустков: путь героя не должен прерываться уроном. +await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); }); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await booted; -await new Promise((r) => setTimeout(r, 1500)); // fade перехода в меню +// Через мост: маршрут A* до триггера (2,2) — клики в юнитах, видимость не важна. +const arrived = await agent.walkTo(2, 2); +if (!arrived) console.log(' ! герой не дошёл до (2,2)'); -// «Новая игра» (первый пункт в свежем профиле; канвас 480x270 растянут x2) -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); - -// Шагаем к переходу (2,2): в изометрии он строго «вверх экрана» от старта -// (герой идёт по диагонали tx=ty, экранная X неизменна). Экран->мир зависит -// от камеры, поэтому точку клика вычисляем в странице: на 2 тайла выше ног -// героя в сторону триггера. Каждый клик подтягивает героя к (2,2). -async function stepClick() { - return page.evaluate(() => { - const g = window.__game; - const sc = g.scenes.current; - const tile = sc.player.currentTile(); - const canvas = document.querySelector('canvas'); - const rect = canvas.getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - const halfH = 8; // DEFAULT_ISO: tileH 16 - const wx = 0; // диагональ tx=ty — экранная X равна 0 - const wy = (tile.x + tile.y) * halfH + halfH - 32; // на 2 тайла выше ног - return { - x: rect.left + ((wx + wr.x) / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height - }; - }).then((p) => page.mouse.click(p.x, p.y)); -} -for (let i = 0; i < 20; i++) { - const near = await page.evaluate(() => { - const sc = window.__game.scenes.current; - return sc && Math.max(sc.player.currentTile().x, sc.player.currentTile().y) <= 5; - }); - if (near) { - // Триггер уже на экране — кликаем точно в его мировую точку. - const p = await page.evaluate(() => { - const g = window.__game; - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - const wy = 4 * 8 + 8; // центр ромба (2,2) - return { x: rect.left + (wr.x / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height }; - }); - await page.mouse.click(p.x, p.y); - } else { - await stepClick(); - } - await new Promise((r) => setTimeout(r, 700)); -} - -// Даём fade-переходу (0.4 с) и enter() новой сцены завершиться до скриншота. -await new Promise((r) => setTimeout(r, 1500)); +// Fade-переход и enter() новой сцены. +await agent.waitFor('s.area === "ponds"', { timeoutTicks: 300 }); +const area = await agent.currentArea(); +console.log(`Локация после перехода: ${area}`); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); +if (!consoleLogs.some((l) => l.includes('[location] ponds'))) { + console.log(' ! маяк [location] ponds не найден'); +} await browser.close(); \ No newline at end of file diff --git a/tools/smoke-quest.mjs b/tools/smoke-quest.mjs index 4880d58..6c7b304 100644 --- a/tools/smoke-quest.mjs +++ b/tools/smoke-quest.mjs @@ -1,52 +1,35 @@ /** - * Смоук квестовой рамки: новая игра → диалог с Ирвином (задание) → + * Смоук квестовой рамки: новая игра → Звенец → диалог с Ирвином (задание) → * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] */ -import puppeteer from 'puppeteer-core'; +import { openGame } from './agent-lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_quest.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); +const { browser, page, agent } = await openGame({ url, newGame: true }); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); -page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); - -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[boot] ассеты загружены')) resolve(); - }); +// Усыпить сгустков: путь героя не должен прерываться уроном. +await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); }); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await booted; -await new Promise((r) => setTimeout(r, 1500)); +// В Звенец, затем клик по Ирвину (12,9) — сцена сама подведёт героя. +await agent.walkTo(26, 14); +await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); +await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); +await agent.tapTile(12, 9); +const talked = await agent.runDialogue(); +console.log(`Диалог с Ирвином: ${talked ? 'сыгран' : 'НЕ открылся'}`); -// «Новая игра» -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); +const flags = (await agent.snapshot()).flags; +console.log('Флаги:', JSON.stringify(flags)); +if (!flags.includes('metElder')) console.log(' ! флаг metElder не поставлен'); -// Клик по Ирвину (тайл (13,15), герой в (14,14)) — диалог. -await page.mouse.click(416, 270); -await new Promise((r) => setTimeout(r, 1000)); - -// Листаем реплики до конца. -for (let i = 0; i < 5; i++) { - await page.keyboard.press('Space'); - await new Promise((r) => setTimeout(r, 500)); -} - -// Открываем сумку с журналом. -await page.keyboard.press('KeyI'); -await new Promise((r) => setTimeout(r, 800)); - +// Сумка с журналом (I) — через инъекцию клавиши. +await agent.key('KeyI'); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); await browser.close(); \ No newline at end of file diff --git a/tools/smoke-zvenets.mjs b/tools/smoke-zvenets.mjs index ce03562..d518c5e 100644 --- a/tools/smoke-zvenets.mjs +++ b/tools/smoke-zvenets.mjs @@ -1,65 +1,32 @@ /** - * Смоук перехода луга -> Звенец: герой идёт кликами на восток карты, - * где триггер (26,14). Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] + * Смоук перехода луга -> Звенец: герой идёт через мост к триггеру (26,14). + * Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] */ -import puppeteer from 'puppeteer-core'; +import { openGame } from './agent-lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_zvenets.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, - // и навигация, завязанная на звук, зависает. - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); +const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); -page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); - -// Ждём маяк BootScene: без него клики уходят «в загрузку». -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[boot] ассеты загружены')) resolve(); - }); +// Усыпить сгустков на лугах: путь героя не должен прерываться уроном. +await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); }); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await booted; -await new Promise((r) => setTimeout(r, 1500)); // fade перехода в меню +// Через мост: маршрут A* до триггера (26,14) — клики в юнитах. +const arrived = await agent.walkTo(26, 14); +if (!arrived) console.log(' ! герой не дошёл до (26,14)'); -// «Новая игра» (первый пункт в свежем профиле; канвас 480x270 растянут x2) -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); - -// Клик в тайл на 2 шага восточнее ног героя (но не дальше триггера): -// центр ромба (tx,ty) — ((tx-ty)*16, (tx+ty)*8+8) виртуальных px. -for (let i = 0; i < 20; i++) { - const p = await page.evaluate(() => { - const g = window.__game; - const sc = g.scenes.current; - const tile = sc.player.currentTile(); - const target = { x: Math.min(26, tile.x + 2), y: tile.y }; - const wx = (target.x - target.y) * 16; - const wy = (target.x + target.y) * 8 + 8; - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - return { - x: rect.left + ((wx + wr.x) / 480) * rect.width, - y: rect.top + ((wy + wr.y) / 270) * rect.height - }; - }); - await page.mouse.click(p.x, p.y); - await new Promise((r) => setTimeout(r, 700)); -} - -// Даём fade-переходу (0.4 с) и enter() новой сцены завершиться до скриншота. -await new Promise((r) => setTimeout(r, 1500)); +await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); +const area = await agent.currentArea(); +console.log(`Локация после перехода: ${area}`); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); +if (!consoleLogs.some((l) => l.includes('[location] zvenets'))) { + console.log(' ! маяк [location] zvenets не найден'); +} await browser.close(); \ No newline at end of file diff --git a/tools/smoke.mjs b/tools/smoke.mjs index e562b99..3e2d55e 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1,37 +1,22 @@ /** - * Смоук-тест игры в реальном браузере (системный Chromium). + * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. * Запуск: node tools/smoke.mjs [url] [файл-скриншота] - * Открывает страницу, ждёт загрузки/меню, печатает консоль браузера и делает скриншот. */ -import puppeteer from 'puppeteer-core'; +import { openGame } from './agent-lib.mjs'; const url = process.argv[2] ?? 'http://localhost:5199/'; const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, - // и навигация, завязанная на звук, зависает. - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); +const { browser, page, agent } = await openGame({ url, newGame: true }); -page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); +// 300 фиксированных шагов без ошибок инвариантов. +await agent.step(300); +const inv = await agent.invariants(); +const errs = inv.filter((i) => i.severity === 'error'); +if (errs.length) console.log(' ! инварианты:', JSON.stringify(errs)); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 4000)); - -// Клик в центр экрана: в меню это «Новая игра», в локации — шаг героя. -await page.mouse.click(480, 283); -await new Promise((r) => setTimeout(r, 2500)); - -// Клик по Ирвину (тайл (13,15), герой стоит в (14,14) в центре) — диалог. -await page.mouse.click(416, 270); -await new Promise((r) => setTimeout(r, 800)); +const snap = await agent.snapshot(); +console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile, hp: snap.hero?.hp })); await page.screenshot({ path: shot }); console.log(`Скриншот: ${shot}`); await browser.close(); \ No newline at end of file