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

Условия ветки: 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. Клик по тайлу вне экрана: в смоук-скриптах с реальными кликами цель за краем канваса (виртуальный 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. Наследуйся от движкового 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 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/* / @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, а не таймаут.
  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. Музыка: спек-партитура MusicSpecrenderMusicStereo (стерео, 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_TONESAREAS.
  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 на конкретных тайлах, клик в непроходимый тайл, телепорт в тело спящего врага (расталкивание). Расширяя коллизии — добавляй шаг туда.
  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 — при новом поле снапшота обнови фабрику и тип в одном месте; сцена/фасад дубликаты не пишут.
  4. Клик-зона объекта — не его ромб-пол: экранно тело спрайта стоит выше ромба, и клик «проваливается под объект». RouterDeps.pixelTile(px, py) — сцена отдаёт тайл по bbox вьюх (NPC/интерактивы) + ремап на высокий тайл (тело дома занимает (tx−1,ty−1), отдаём базовому тайлу — двери кликаются по спрайту); роутер подставляет его ДО resolveClick — чистый резолвер не меняется.

Грабли среды (кратко, подробности в 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-записи не тикаются).