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

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

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

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/`: писать через каркас `withChecks` из
   `apps/game/tools/lib.mjs` (boot/goToArea/sleepEnemies/expectInvariantsClean —
   готовые помощники; сервер гасится сам). Готовый образец —
   `apps/game/tools/checks/transitions.mjs`. Контекст игры — только `t.ctx`:
   деструктуризация до `boot` снимет геттер и даст `null`.

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

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`, граф — JSON в `data/dialogues/<id>.json`
   (источник истины, формат 4 пробела + `\n`), ключ — в реестр
   `data/dialogues.ts`.
2. Правила графов общие (`data/dialogueRules.ts`): валидатор проверит
   несуществующие `next`/`choices` и ссылки вне реестров → `error`,
   недостижимые узлы → `warn` (сироты допустимы, но проверь, что это не
   забытая ветка), цикл без текста → `error`. Прогнать глазами:
   `npm run dialogues:dry [<id>]` — реплики на пресетах состояния + сироты/циклы.
   Править граф глазами — визуальный редактор `npm run dialogues` (порт 5299):
   карточки/рёбра, инспектор узла, кнопка Save гоняет тот же checkGraph
   (422 на ошибках, mtime-гард от конкурентной правки), dry-run прямо в UI.
   Правила одни для всех трёх точек (валидатор, CLI, редактор) — они в
   `dialogueRules.ts`; сервер редактора спавнит `tools/dialogues/probe.ts`.
3. Проверка через мост: `t.talkTo(npcId)` (подойти и нажать E) →
   `runDialogue()` → прочитать `flags`/`dialogue.text` из снапшота
   (`s.dialogue.path` — какие узлы реально показаны). Нюанс typewriter:
   `advance` при открытой реплике сначала **догоняет печать**, а при
   `waitingForChoice` **активирует курсор** (берёт подсвеченный вариант!) —
   сценариям, идущим до конкретного выбора, надо проверять
   `s.dialogue.waitingForChoice` перед каждым press и останавливаться.
   Выбор в сценарии — `pickChoiceByText('текст')`, не по индексу.

Панель/текст, зависящий от контента: вьюху (панель диалога, кнопку, тост)
размечай по **полному** тексту, а не по напечатанному остатку — при
typewriter пустая строка схлопывает панель, и текст печатается поверх мира.
Измерение — временно поставить полный текст, снять `height`, вернуть
состояние (см. `DialogueBox.show`); перенос длинных строк — `wordWrapWidth`
в `PixelText`, а не ручная нарезка.

Условия ветки: `when`/`whenNot` — флаги, `whenVars` — несколько переменных
(AND), `hasItem` — предметы. Предметы раннер знает только через
`DialogueWorld` (игра передаёт `setHooks({ world })`) — без него
`hasItem`-ветки скрыты. Условие нужно **вне диалога** (квест-стадия,
фильтр контента) — не пиши замыкание `ready: (s) => ...`: вызывай чистый
`evalConditions(c, state, world)` — то же правило, что проверяет граф, и
валидатор его видит. Сюжетные действия («дал предмет», «звук», «поляна
зазеленела») — не код в `onFinish`, а данные `do: [{ kind, ... }]` в
узле/выборе: игра получает их через `runner.onEffect` (см.
ui-and-dialogue.md «Игровые эффекты»), валидатор проверяет id.

## Ситуация: упал смоук / проверка

См. `agent.md` «waitFor и переходы сцен». Частые причины, по частоте:

1. **Действие проглочено fade-переходом**: `begin()` молча отбрасывает
   параллельный переход, сцена игнорирует клики при `transitioning`. Перед
   действием — `waitFor('!s.transitioning')`.
2. **Цель вне радиуса действия**: взаимодействие работает только в радиусе
   (евклидовы юниты, ~1.5) — сначала `walkTo`/`approach`, потом
   `press('interact')`; в lib.mjs это `t.interactAt` и обёртки над ним.
3. **walkTo в непроходимый тайл** (NPC, высокий объект): `scene:walk` вернёт
   false — путь не строится. Для таких целей есть `approach(tx, ty)`
   (scene:approach ведёт к ближайшему проходимому соседу и возвращает тайл
   прибытия) — после него и жми действие.
4. **Гонка rAF и инъекции**: инъекция и шаг должны быть в одном JS-стеке —
   высокоуровневые методы моста (`press/key/walkTo/approach`) уже атомарны;
   сырые `inject*` из страницы + отдельный `step` — гонка с rAF.
5. **Бой: facing после `approach` не смотрит на врага** — конус бьёт по
   facing, а подход задаёт его только последним отрезком пути (может прийти
   перпендикулярно). Доворот: короткий `press(направление, 5)` в сторону врага
   перед `press('attack')`. Направление — по экранным компонентам смещения
   герой→враг (`sx = dx − dy`, `sy = dx + dy`); клавиши движения — экранный
   вектор (`moveFree` делает `screenToWorld`), так что одиночное направление
   даёт верный facing. Хелпер — `fightNearest` в `apps/game/tools/lib.mjs`.
6. `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. Наследуйся от движкового `MenuSceneBase` (см. `docs/engine/ui-and-dialogue.md`,
   раздел «Меню-сцены»): в `build()` — Panel/PixelText/MenuList и
   `uiRoot.addChild(this.view)`; ввод и гард `transitioning` — в каркасе.
2. Карта действий — вторым аргументом super: `{ up, down, confirm, cancel }`
   (+ `left/right` для значений, `extra` для прочих). Имена действий — из
   биндов `main.ts`, не выдумывай новые без бинда.
3. Сцена под push стоит на паузе (`SceneManager.update` тикает только
   верхнюю) — «заморозку мира» делать не надо.
4. Пересборка списка (`setItems`) сохраняет фокус; недоступные пункты —
   `disabled: true` (курсор их пропускает), а не no-op-колбэк.

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

1. Модель — движковый `Inventory` (`docs/engine/inventory.md`): стаки неявные,
   `add` возвращает, сколько влезло (лимиты обрезают, не бросают).
2. Имя/описание — `ItemDef` в `data/items.ts` (литеральный union `ItemId` —
   опечатка не скомпилируется); движение предметов — `inventory.add/remove`
   в `Game.ts`.
3. Лимиты сумки — опции конструктора в `Game.ts` (`{ maxSlots, maxPerStack }`);
   UI читает `inventory.all` — формат строки («имя ×N») только в
   `inventoryItems` (`data/quests.ts`), не форматируй в сцене.
4. Изменения сумки можно слушать: `inventory.onChange((ch) => ...)`
   (`InventoryChange { kind, id, count, after }`) — например, для HUD.

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

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.timing.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`/`approach`/`press`), не через реальные клики
   по CSS-пикселям; взаимодействие — кнопкой действия после подхода.
4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов.
5. Опечатка в ключе предиката `waitFor` больше не тихий таймаут: мост сразу
   вернёт `{ ok: false, error: 'неизвестный ключ…' }` (список —
   `AGENT_SNAPSHOT_KEYS`). Если ждёшь `ok: false` без `error` в деталях —
   смотрим `error`, а не таймаут.
6. Новый метод моста добавляется **в трёх местах**: `AgentApi`/`GameAgent` +
   `registerAgent` (apps/game/src/agent/GameAgent.ts) и прокси `AgentClient`
   (packages/engine/tools/agent-lib.mjs) — у клиента белый список методов,
   без строки там сценарий получит «not a function». Полный цикл сюжета —
   образец `checks/quest-bells.mjs` (квест «Три цветка» от взятия до эпилога).

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

1. **sfx — спек-синтез (файлов больше нет)**: ключ + `SoundSpec` в реестр
   `SPEC_SFX` (`data/sfxSpecs.ts`), play-сайт зовёт хелпер
   `playSfx(audio, key, opts)` (не `audio.play` напрямую). Спек компилирует
   движок (`renderSpec`, kind `hit`/`chime`/`scrape`/`hum`/`tone` + параметры
   `freqTo`/`w`/`attack`/`fadeTo`/`wobble*`/`layers` — см. «Спек-синтез» в
   assets-audio-save.md); ключи попадают в `AUDIO_KEYS` через
   `SPEC_SFX_KEYS` — validate-тест ловит ключ контента вне реестра. Новый
   звук по формуле бывшего WAV (или «как соседний») — формулы бывшего
   генератора экспортированы в `tools/audio/gen.mjs`, парити-тест
   `sfxSpecs.test.ts` сверяет `renderSpec` с формулой численно (бит-в-бит
   или eps 1e-5): генератор при импорте WAV не пишет (CLI-гвард
   `import.meta.url === pathToFileURL(process.argv[1]).href`).
2. **Файловые лупы (амбиент)**: функция-синтез в
   `apps/game/tools/audio/gen.mjs` (примитивы движкового
   `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth, decay,
   loopify; сиды фиксированы — звук детерминирован) + запись в словарь
   `AMBIENCE`; `npm run audio` перегенерирует WAV в
   `apps/game/assets/audio/ambience/`.
3. Ключ лупа — в реестр `apps/game/src/data/audio.ts` (`AMBIENCE_KEYS` /
   `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда (спек-ключи не
   префетчатся — их компилирует движок на месте).
4. Как играть: разовый sfx — `playSfx(audio, key, {volume, rate, pan})`
   (число = громкость; `restart: true` для «одиночного» звука вместо
   наслоения экземпляров); позиционный —
   `worldAudio.playAt(key, pos, base, radius)` (затухание + панорама от
   героя, тишина за радиусом); шаги —
   `worldAudio.playStep(surface, ...)`, где surface — `'grass'|'ash'|'water'|
   'floor'` (варианты — `renderSpec` с сидами base+i·10 в
   `systems/StepVariants.ts`, до unlock играет базовый спек).
   Событийные звуки боя/фауны —
   подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle`
   (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные
   слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`;
   слой без `at`/`tiles` — глобальный (ветер `ambience/wind`: громкость
   константой, радиус не нужен). «Мир живой» — редкие дальние шорохи
   `sfx/rustle` из тика сцены (`LocationScene.updateAmbient`: rng сцены,
   случайные pan/rate/volume; перезапуск 10–30 с). Птиц в мире НЕТ — лор
   (docs/world.md: «птицы молчат»), звук пернатых — слом мира.
   Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) —
   пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта»
   одного ключа. Агентный доступ — op `scene:synthesize`, образец —
   `checks/synth.mjs`.
   Музыка: спек-партитура `MusicSpec` → `renderMusicStereo` (стерео, `pan`
   треков; до стерео был `renderMusic`) → `createBuffer(data, RATE, 2)` →
   `playLoopBuffer` (см. «Спек-синтез» и «Музыка» в assets-audio-save.md) —
   генератор нот с сидом в `data/music.ts`, у области — поле `theme`,
   у Game — `playTheme(key)` с guard-ом и кроссфейдом; WAV для музыки не нужен.
   Динамическая музыка — тик по состояниям врагов (`LocationScene.updateMusic`):
   chase → `playTheme('combat')`, wary → тихий стем `playLoopBuffer` с
   `setVolume` поверх темы области; темы боя живут в реестре `THEMES` наравне
   с темами областей. Луп, заводимый из тика, — **один** запуск: флаг
   «промис не вернулся» (`stemPending`), иначе каждый кадр до резолва промиса
   заводит новый луп и заливает аудио-лог (пробы audio/music краснеют).
   Room tone интерьера — hum-спек в `ROOM_TONES` (`data/sfxSpecs.ts`, ключи —
   строковые id областей, БЕЗ типа AreaId — иначе цикл импортов), запуск в
   `LocationScene.enter` на шине ambience, стоп в exit; validate-тест: ключи
   `ROOM_TONES` ⊆ `AREAS`.
5. На слух в headless не проверить — смотреть фактические запуски: DEV-шпион
   `window.__gameAudioLog` (кольцо на 24, `t`/`volume`/`pan`) читается из
   страницы; образец — `apps/game/tools/checks/audio.mjs`. После действия
   опрашивай лог с паузами (`waitAudio`): компиляция спека/декод WAV
   занимает заметное время, шаг(60) может не хватить.
6. Грабли: `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 на конкретных тайлах, scene:walk в непроходимый — false и
   approach к его соседу, телепорт в тело спящего врага (расталкивание).
   Расширяя коллизии — добавляй шаг туда.
4. Грабли проб: `scene:teleport` на тайл step-перехода запускает переход
   (сцена сменится — телепортируйся на соседний тайл); `walkTo` применим
   только к проходимым целям (ждёт героя В целевом тайле) — для непроходимых
   используй `approach(tx, ty)` (вернёт тайл прибытия).

## Ситуация: меняю роутинг взаимодействия / агентный мост сцены

1. `LocationScene` — только оркестратор вьюх: текстуры, композитинг, ввод,
   камера, катсцены. Логика взаимодействия живёт в `systems/InteractRouting.ts`
   (чистый `pickInteract` + рантайм `InteractionRouter` с deps-объектом),
   мост — в `agent/SceneAgentView.ts`. В сцену новые ветки ввода не добавлять —
   расширяй кандидаты/пикер (приоритеты кнопки действия: NPC → переход/заперто →
   интерактив → цветок) и юнит-тесты к ним (`InteractRouting.test.ts`).
1а. Две линейки мира (не путать): **проекционная** (`inCircleW`/`inConeW`/
   `worldDist`) — для конусов удара и экранно-проекционных хитбоксов,
   анизотропна (2:1); **истинная евклидова** (юнит = 1 тайл, `Math.hypot`
   позиций) — для радиусов взаимодействия. Радиус в проекционной линейке даёт
   зону в 2-3 тайла, вытянутую по мировой диагонали: взаимодействие
   «срабатывало издалека». Эталон — `inInteractRangePos` в `InteractRouting.ts`.
2. Приоритеты кнопки действия меняются только вместе с тестами пикера и
   `agent check` (проба `transitions` проверяет колодцы, `interact` — объекты).
3. Слои снапшота собираются фабриками из `agent/snapshot.ts` — при новом поле
   снапшота обнови фабрику и тип в одном месте; сцена/фасад дубликаты не пишут.
4. Взаимодействие — только по тайлу цели в радиусе (def.tile, евклидова
   линейка): никаких хитбоксов тела спрайта. Клик-роутинг удалён (движение —
   WASD/стик/джойстик, взаимодействие — кнопкой действия E/A); мышь в мир
   не маршрутизируется вовсе.

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

1. **Свет живёт в `renderer.lightRoot` — экранное пространство, не в
   `worldRoot`.** Ambient обязан быть экранного размера (fullscreen multiply),
   источники — над затемнением (additive под multiply умрёт). Всё, что
   «пятно на экране» (гало, виньетка, вспышка), — туда же; что едет с миром —
   в `worldRoot`.
2. **Проекция мировых точек в экранные слои — `camera.toScreen(wx, wy)` каждый
   тик** (см. `GameLighting.update`): без лага на кадр и без чтения
   `worldRoot.position` (он обновляется позже — свет отставал бы от камеры на
   шаг). Позиции источников сцена считает сама — движок изометрии не знает.
3. **Runtime-статика**: дефы области (`AreaDef.lighting`) — только начальное
   состояние; всё эффектное — через runtime API (`setAmbient` с лерпом,
   `addLight`/`removeLight`) — база day/night и визуальных эффектов. Условные
   источники (очаг после квеста) — через `whenFlag`/`notFlag` в данных, не кодом.
4. Интенсивность в снапшоте — пост-мерцание: проверки сравнивают с допуском
   (детерминизм даёт `seedOf(id)` по FNV-1a), инвариант `lighting-bounded` —
   на границы, не на равенство.
5. **День/ночь — непрерывное время суток, не фейд и не сюжетный флаг.** Игра
   держит `GameClock` (1 с реального = 1 игровая минута, старт 8:00) и каждый тик
   подаёт часы в `GameLighting` через деп `timeHours`; цель ambient —
   `lerpAmbient(base, night, dayNightFactor(hours))` с guard по последней цели
   (`setAmbient(target, 0)` — закат уже растянут кривой суток на ~2 игровых часа,
   отдельный фейд не нужен). Ночные источники — `whenNight: true` в данных
   (горят при факторе ночи ≥ 0.5), лампа героя — по тому же порогу. Время пиновать
   в проверках (`scene:setTime`) перед каждым ambient-ассертом — реальный темп
   уезжает за минуты.
6. **Импульсы (вспышки) — тот же пул источников**: движковый `pulseLight` занимает
   спрайт пула и снимается сам по концу огибающей (`PulseSpec`/`pulseEnvelope`
   в lightSim — чистая математика, тестируется в node); снапшот и инварианты
   видят импульсы бесплатно. Вспышка «на весь экран» (`pulseAmbient`) — один
   слот, последний побеждает. В сценах не забыть `maxLights: MAX_LIGHTS` —
   иначе движковый пул (16) молча теряет источники сверх себя.
7. **Механика «тьма с прорезями»**: тьма — карта силы (RT: белый fill →
   erase-спрайты), display-спрайт `multiply` с `tint = ambient`. Отсюда два
   правила: (а) **bake-контейнер с erase-спрайтами не добавлять в дерево
   сцены** — `destination-out` вне RT стирает мир; (б) **дорогая перерисовка
   гасится квантованной подписью** `{round(x), round(y), scale·1/8, alpha·1/8}`:
   bake только при её изменении, «живость» мерцания переносится в слои,
   рисуемые каждый тик (тон, ambient-лерп). Смена цвета без смены геометрии
   (день/ночь) — просто `tint` display-спрайта, RT не трогается. День с белым
   ambient — fast-path: слой выключен целиком.

## Ситуация: добавляю визуальный эффект

1. **One-shot эффект — через `fx.<preset>()` слоя `render/fx/`, не руками**:
   кольцо (`ring`), всплывающий текст (`floatText`), растворение (`fade`),
   pop-появление (`pop`), хит-флэш (`flash`), разовые частицы (`burst` +
   пресеты `spark/smoke/ember/dust`), шлейф (`trail`). Слой добавляется в
   сцену один раз (`engine.fx.add(fx)`) и сам тикает/вычищает всё, что спавнит.
2. **Никогда не добавлять эмиттер в мир без реестра**: `world.addChild(emitter)`
   без `engine.fx.add` (или без слоя) = «эффект висит статичной графикой и
   не уничтожается» — класс бага, который слой чинит сам по себе.
3. Нужен свой тип эффекта — сэмпл-математика в `fxSim.ts` (headless-тест),
   тонкий адаптер `Fx* implements Updatable` с `cancel()`/`done`/`destroyed`,
   метод-фабрика на `FxLayer`. Хелперы ≤70 строк.
4. Эффекты по группе: слой на группу (мир / UI) — `clear()` гасит только свой
   слой; зацикленные телеграфы (`repeat: Infinity`) гасит сцена через `cancel()`.

## Грабли среды (кратко, подробности в 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-записи не тикаются).- **Тест ядра без WebGL**: `Engine`/`Renderer` тестируются в Vitest через
  `vi.mock('pixi.js')` (стабы `Container`/`Graphics`/`Application` с
  `renderer.render`/`canvas.style`) — см. `core/__tests__/Engine.test.ts`.
  `init()` в тесте не зовётся (он и не нужен: шаги — через `stepTicks`).
- **Класс-поле не может называться как метод**: поле `private to: T` в классе
  с методом `to()` создаёт own-свойство и метод «пропадает»
  (`f.to is not a function`, TS не предупреждает) — переименовывать поле
  (`toVal`), см. `core/fade.ts`.
