# Практики работы агента с движком и игрой

**Живой документ**: каждая новая находка (грабля, удачный приём, экономящая
время последовательность) пополняет этот файл **в том же коммите**, где она
появилась. Структура — по ситуациям агента, не по подсистемам: нашёл свою
ситуацию → скопировал последовательность шагов.

## Ситуация: проверяю своё изменение

1. `npm run typecheck` — быстрый отсев.
2. `npx vitest run <затронутый тест>` — точечный прогон.
3. `node apps/game/tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты +
   карты + карты-файлы равны генераторам + браузерная проверка инвариантов +
   сценарий переходов `apps/game/tools/checks/transitions.mjs`).
4. Смоук акта 1 (`node apps/game/tools/smoke-act1.mjs`) — не сломал ли существующий геймплей.

## Ситуация: добавляю локацию

1. Генератор в `apps/game/tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`.
2. `AreaId` + `AreaDef` в `data/locations.ts`: spawn/npcs/enemies/transitions.
3. Экспорт через `AREAS`/`areaOf` и загрузка в `BootScene` по `Object.keys(AREAS)`.
4. Валидатор проверит спавн/врагов/переходы автоматически (`agent:invariants`):
   **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен
   на воду/дерево). Чинить данные, а не валидатор.
5. Сценарий в `apps/game/tools/checks/`: переход туда и обратно через `walkTo` + `waitFor`
   (готовый образец — `apps/game/tools/checks/transitions.mjs`).

## Ситуация: меняю генераторы арта или карт

1. Правки генератора + перегенерация в одном коммите: `npm run art` → `npm run art:lint`
   → `npm run maps`. Новые файлы — заодно в `Game.ASSET_KEYS` и `tileTextures()`.
2. **Fresh-тест карт падает первым прогоном всегда**, если данные генератора изменились:
   он сравнивает диск с генератором ДО записи (запись идёт в том же прогоне позже).
   Второй прогон должен быть зелёным — если нет, читай diff: там реальное расхождение.
3. Грабля `-0`: `Math.round` может вернуть `-0`, JSON его теряет (`"-0"` → `0`) —
   fresh-тест разъезжается на равных на вид числах. Нормализуй: `Math.round(x) + 0`.

## Ситуация: добавляю переход (дверь, портал, колодец)

1. Один механизм на всё — `TransitionDef` в `data/locations.ts`: `trigger: 'step'`
   (наступил, выходы между областями) или `'click'` (двери/колодцы; герой подходит
   к тайлу сам). Условия — `requiresFlag`/`requiresItem` + `lockedText` (тост).
2. Карту грузит `npm run maps`; тайл-триггер может быть непроходимым (колодец) —
   клик-переход обрабатывается до маршрутизации движения.
3. `npm run maps` при провале «fresh»-теста чинит файлы, **но сначала прочти
   провал**: расхождение «тайлов N, ожидалось W×H» = индекс за пределами карты
   в генераторе (разреженный массив дорос, см. граблю ниже).
4. Проверка: `apps/game/tools/checks/transitions.mjs` — шаг до перехода, клик-переход
   туда и обратно, снапшот `s.transitions` (триггеры и цели).

## Ситуация: добавляю интерьер / интерактивный объект

1. Карта интерьера: `buildInteriorMap()` в `data/map.ts` (рамка WALL, пол FLOOR,
   дверной проём снизу) — не рисуй рамку руками.
2. Дверь внутрь — `TransitionDef { trigger: 'click' }` в `AreaDef` улицы;
   выход — `TransitionDef { target: { kind: 'return' }, trigger: 'step' }` в
   интерьере (проём). `returnTo` фиксируется **в момент клика по двери**
   (тайл героя), не в момент выхода — в сценарии храни тайл клика и сравнивай
   с ним после возврата.
3. Объекты — `InteractableDef` в `INTERACTABLES` (`data/interactables.ts`):
   реакции по `when` (первая подходящая), `once` поднимает флаг `used:<id>`,
   эффекты — `setFlags`/`setVar`/`gives`, текст виден в `s.lastToast`.
4. Валидатор сам проверит объекты в границах и переходы; полная проверка —
   `apps/game/tools/checks/interact.mjs` (образец: вход → сундук → used → выход →
   возврат на тайл клика).
5. `waitFor` моста видит **полный** снапшот игры (`s.flags`, `s.inventory`,
   `s.interactables`) — предикаты над прогрессом пиши против него, а не
   против сценических полей.

## Ситуация: добавляю 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`) — сцена подведёт героя сама.
   **Интерактив на ПУТИ**: `walkTo` кликает по узлам маршрута, и клик в тайл
   мота/цветка срабатывает как взаимодействие — герой останавливается рядом.
   Начиная с батча 3 такие узлы пропускаются автоматически; в старых сценариях
   веди героя через промежуточные точки в стороне от объектов.
4. **Гонка rAF и инъекции**: инъекция и шаг должны быть в одном JS-стеке —
   высокоуровневые методы моста (`tapTile/press/key/walkTo`) уже атомарны;
   сырые `inject*` из страницы + отдельный `step` — гонка с rAF.
5. `s.error` в снапшоте — мост поймал исключение, читай текст.

## Ситуация: добавляю флаг / вар / предмет

1. Флаг — ключ в `data/ids.ts` → `FLAGS` (вар — в `VARS`), в контенте и коде
   только константа (`FLAGS.quest_bells_taken`), не литерал. Опечатка в
   литерале ловится `validateReferences()` (error `flag-unknown`), ключ
   реестра, нигде не упомянутый, — warn `flag-dead`.
2. Движковые поля (`DialogueEffects.setFlags`, `InteractWhen.flag`,
   `TransitionDef.requiresFlag`) — строки: реестр живёт в игре, сверяется
   только валидатором. Игровые типы (`NpcDef.flagKey`, `QuestStage.doneFlag`)
   типизированы `FlagId`/`VarId` — там опечатка не скомпилируется.
3. used-флаг одноразового интерактива — только через `usedFlag(id)`
   (`used:<id>` в трёх местах конкатенировался руками — расхождение ловится
   инвариантом `interact-used-consistent`).
4. Синтаксис знакомств — camelCase (`metElder`), сюжета — snake_case; это
   легаси в сейвах, не унифицировать без миграции.

## Ситуация: коммичу

1. `pre-commit` хук (`.githooks/`, подключается `npm run prepare` →
   `git config core.hooksPath .githooks`) гоняет `npm run check:fast`
   (typecheck + guard + юнит-тесты, ~30 с). Провал = коммит отменён;
   пропустить разово — `git commit --no-verify` (осознанно: хук не гоняет
   браузерные пробы).
2. Полная сетка перед пушем — `node apps/game/tools/agent.mjs check` +
   смоук акта (`node apps/game/tools/smoke-act1.mjs`).
3. Если коммит состоит из изменений движка — доки `docs/engine/` в том же
   коммите (см. «меняю API движка»).

## Ситуация: меняю API движка

1. Обнови соответствующий `docs/engine/*.md` и этот файл (если появился новый
   приём) **в том же коммите**.
2. Экспорт только через `packages/engine/src/index.ts` — под-пути движка
   импортировать нельзя.
3. `npm run typecheck` ловит разрывы в обоих пакетах; тесты движка — в Vitest
   без браузера (Pixi-зависимости — через стабы, см. `GameLoop.manual.test.ts`).

## Ситуация: меняю границу движок / игра

1. Правила границы проверяет гвард: `npm run guard`
   (`apps/game/tools/guards/boundary.mjs`), он же проба `guard:boundary` в
   `agent check`. Провал = импорт мимо публичного API движка
   (`@rpg/engine` / `@rpg/engine/assets/*` / `@rpg/engine/tools/*`), относительный импорт,
   пересекающий границу пакета, или импорт игры из движка. Чинить импорт,
   а не гвард.
2. Движок не знает ничего об RPG-контенте; если движку нужна точка расширения
   — интерфейс с инъекцией (образцы: `StorageLike`, `SceneAgent`), а не
   импорт из `apps/game`.
3. Чистое ядро гварда (`importSpecs`/`checkBoundary`) живёт в движке
   (`packages/engine/tools/boundary.mjs`, тесты рядом в `__tests__/`) — новый
   класс нарушений добавляй как правило + тест; корни пакетов и публичное
   API — опции, сценарий запуска конкретного репо — у приложения.

## Ситуация: пишу новый сценарий apps/game/tools/checks/

1. Каркас: `import { startDevServer, openGame, Checks } from '../lib.mjs'`
   (сценарный слой игры поверх жанронезависимого `@rpg/engine/tools/agent-lib.mjs` —
   там маяк загрузки игры);
   `export default async function ({ pretty })`; вернуть
   `c.finish({pretty}).ok ? 0 : 1` — CLI `agent.mjs run` ждёт числовой код.
2. Проверка: возвращает `false` или кидает исключение при провале (строка —
   это детали успеха!); удобнее `c.expect(cond, msg, details)`.
3. Ввод — через мост в юнитах (`walkTo`/`tapTile`/`press`), не через реальные
   клики по CSS-пикселям.
4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов.
5. Опечатка в ключе предиката `waitFor` больше не тихий таймаут: мост сразу
   вернёт `{ ok: false, error: 'неизвестный ключ…' }` (список —
   `AGENT_SNAPSHOT_KEYS`). Если ждёшь `ok: false` без `error` в деталях —
   смотрим `error`, а не таймаут.

## Ситуация: добавляю звук

1. Генерация: функция-синтез в `apps/game/tools/audio/gen.mjs` (примитивы
   движкового `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth,
   decay, loopify; сиды фиксированы — звук детерминирован; WAV-энкодер и
   запись — `wav.mjs` поверх них) + запись в словарь `SFX`; `npm run audio`
   перегенерирует WAV в `apps/game/assets/audio/`.
2. Ключ — в реестр `apps/game/src/data/audio.ts` (`SFX_KEYS` / `AMBIENCE_KEYS`
   / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда, validate-тест ловит
   «ключ в контенте — файла нет» и «файл есть — ключа нет».
3. Как играть: разовый sfx — `audio.play(key, {volume, rate, pan})` (число =
   громкость; вернёт `SfxHandle` — параметры в полёте и stop; `restart: true`
   для «одиночного» звука вместо наслоения экземпляров); позиционный —
   `worldAudio.playAt(key, pos, base, radius)` (затухание + панорама от
   героя, тишина за радиусом); шаги —
   `worldAudio.playStep(surface, ...)`, где surface — `'grass'|'ash'|'water'|
   'floor'` (вариативные процедурные шаги из `systems/StepVariants.ts` —
   3 буфера на поверхность, до unlock движок играет файловый шаг).
   Событийные звуки боя/фауны —
   подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle`
   (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные
   слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`.
   Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) —
   пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта»
   одного ключа. Звук **без файла вообще** — `audio.playSpec(key, spec)`:
   спек `SoundSpec` (kind `hit`/`chime`/`scrape`/`hum` + параметры) движок
   детерминированно компилирует сам — путь для ИИ-агента и быстрых прототипов.
4. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион
   `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из
   страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия
   опрашивай лог с паузами (`waitAudio`): декод WAV занимает заметное время,
   шаг(60) может не хватить.
5. Грабли: `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required`
   в headless не резолвится (см. «Грабли среды»); амбиент не должен
   перезапускаться на том же ключе — guard в `Game.playAmbience`.

## Ситуация: добавляю врага / меняю ИИ

1. Вид — `data/enemies.ts` (`ENEMY_KINDS`): базовые статы + поля ИИ
   (`aggroRange`, `deaggroSec`, `fleeBelowHpFraction`, `hearRadius`, `warySec`;
   дефолты — `withDefaults`). Держись: спящий (dormant) будится только гулким
   звуком (шум ≥ 0.7), шаги героя (0.35) лишь настораживают бодрых.
2. Патруль — **per-spawn**, не per-kind: в `AREAS[*].enemies` у точки спавна
   `patrol: { points: [тайлы], pauseSec }` (маршрут специфичен для инстанса;
   per-kind заставил бы ползунов разных областей ходить по одним тайлам).
   Точки проверяй по ASCII-дампу карты (проходимость) и держи маршруты вдали
   от игровых троп, иначе сломаются смоуки акта.
3. Проверка без боёв: `scene:noise`/`scene:damageEnemy`/`scene:teleport`
   (см. `docs/engine/agent.md`) + состояния из `s.enemies[i].state`.
   Пример — `apps/game/tools/checks/ai.mjs`.
4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` —
   это то, что подал тест (см. `EnemyAI.test.ts`).

## Ситуация: проверяю коллизии / движение

1. Тела — круги в юнитах: герой 0.35, враги/NPC из kind-дефов; правило
   **радиус < 0.5** (тело уже тайла — A*-путь по центрам остаётся проходимым).
   Скольжение стен даёт `moveCircle` (оси раздельно), расталкивание —
   `separateCircles` через `registry.near` (каждый актор двигает только себя).
2. Карта коллизий агенту — слой `s.collision {width, height, blocked, props}`;
   точечные вопросы — `scene:walkable {x, y}`, прямая видимость —
   `scene:raycast {from, to}` (см. `docs/engine/agent.md`).
3. Проба-эталон — `apps/game/tools/checks/collision.mjs`: слой валиден,
   walkable/raycast на конкретных тайлах, клик в непроходимый тайл, телепорт
   в тело спящего врага (расталкивание). Расширяя коллизии — добавляй шаг туда.
4. Грабли проб: `scene:teleport` на тайл step-перехода запускает переход
   (сцена сменится — телепортируйся на соседний тайл); `walkTo` применим
   только к проходимым целям (ждёт героя В целевом тайле) — для клика в стену
   используй `tapTile` + `waitFor('!s.hero.moving')`.

## Ситуация: меняю клик-роутинг / агентный мост сцены

1. `LocationScene` — только оркестратор вьюх: текстуры, композитинг, ввод,
   камера, катсцены. Логика кликов живёт в `systems/ClickRouting.ts`
   (чистый `resolveClick(probe)` + рантайм `InteractionRouter` с deps-объектом),
   мост — в `agent/SceneAgentView.ts`. В сцену новые ветки кликов не добавлять —
   расширяй резолвер (приоритеты: NPC → переход/заперто → интерактив → цветок →
   враг → движение) и юнит-тесты к нему (`ClickRouting.test.ts`).
2. Клик-приоритеты меняются только вместе с тестами резолвера и
   `agent check` (проба `transitions` проверяет колодцы, `interact` — объекты).
3. Слои снапшота собираются фабриками из `agent/snapshot.ts` — при новом поле
   снапшота обнови фабрику и тип в одном месте; сцена/фасад дубликаты не пишут.

## Грабли среды (кратко, подробности в CLAUDE.md)

- **Раскладка тулз по правилу «знает ли контент игры»**: знает (палитра,
  тайлы, локации, сценарии акта) — `apps/game/tools/` (сценарии применения);
  нет — `packages/engine/tools/` (png/canvas/imaging/wav/agent-lib/boundary,
  импорт из игры через `@rpg/engine/tools/*` из `exports` движка). Обе —
  3 уровня ниже корня: скриптам, спавнящим процессы с путями от корня репо,
  нужен `ROOT = new URL('../../..', import.meta.url)` (не `'../..'`).
  Коварство: `npm run` из `apps/` всё равно сработает (npm ищет package.json
  вверх), а вот vitest с корне-относительным путём файла — упадёт «не зелёный».
- Контентный параметр выноси из движковой тулзы, не дублируй: палитра —
  параметр `quantize`/`Canvas`, маяк загрузки — параметр `openGame({beacon})`,
  корни пакетов гварда — опции `checkBoundary`.
- `Assets.load` без `Assets.init()` висит навсегда — грузи только через `AssetLoader`.
- **`wav.mjs` тянет `node:fs` — в рантайме браузера не соберётся** (маяк загрузки
  молчит, страница мертва). Рантайм-синтез — из `@rpg/engine/tools/synth.mjs`
  (чистые примитивы, без node-импортов); wav.mjs — только в тулзах при сборке.
- Headless Chromium: `--autoplay-policy=no-user-gesture-required` обязателен;
  `ctx.resume()` без него не резолвится.
- Маяки консоли: `[boot] ассеты загружены` (BootScene), `[location] <id>`
  (вход в локацию) — на них строится `openGame`.
- `window.__agent` только в DEV-сборке; для прод-сборки — `VITE_AGENT=1` (когда понадобится).
- Vite-алиас `@rpg/engine` — только regex; строковый перехватывает под-пути.
- **Запись мимо карты в генераторе не падает**: `tiles[at(x,y)]` с индексом за
  W×H молча дорастит разреженный массив — ловится только тестом «fresh»
  («тайлов 583, ожидалось 560»). После правки генератора всегда `npm run maps`.
- **Асинхронный шаг в каркасе Checks**: `stepResult` обязан `await fn()` —
  без await промис считается успехом (ok:true, ms:1, details:{}).
- **Мост глотает исключения шага**: `EngineAgent.step` ловит throw от
  `stepTicks`, а симптом залипшего ручного режима — игра мертва в реальном
  времени (tick в снапшоте не растёт), переход `transitioning` не кончается,
  в консоли пусто. Диагностика: в странице обернуть `__game.engine.fx /
  tweens / scenes` — их `update` в try/catch с `console.error(e.stack)` —
  и продёрнуть переход; настоящий стектрейс всплывёт. Причина однажды
  была в тике разрушенного Pixi-объекта из `engine.fx` (см. core.md:
  destroyed-записи не тикаются).