Newer
Older
rpg / docs / engine / practices.md

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

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

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

  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 artnpm run art:lintnpm 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/choiceserror, недостижимые узлы → 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.tsFLAGS (вар — в 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 preparegit 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/*), относительный импорт, пересекающий границу пакета, или импорт игры из движка. Чинить импорт, а не гвард.
  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/wav.mjs — bandNoise, bellPartial, synth, decay, loopify; сиды фиксированы — звук детерминирован) + запись в словарь 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(stepKey(tile), ...). Событийные звуки боя/фауны — подпиской в AudioSystem.attach() на combat:* / fauna:startle (симуляция чиста от аудио). Лупы областей — AreaDef.ambience, локальные слои (вода, гул) — AreaDef.ambienceLayers + тик worldAudio.setLayers. Полифонию ограничивает движок (maxVoices, воровство тихих голосов) — пер-ключевой интервал в AudioSystem нужен только против «пулемёта» одного ключа.
  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.
  • 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-записи не тикаются).