Skip to content
Back to skills

Gameplay Testing

ASecurity

Пишет и прогоняет headless-проверки игрового процесса Astral Genesis (генератор уровня, модель тела и распада) плюс ведёт чек-лист ручного плейтеста. Запускать при отладке игровой логики, после правок в GECS-системах, генераторе уровня, дверях/слотах, body-snatch/decay-модели или интеракции, когда нужно покрыть новый инвариант проверкой, и перед тем как считать задачу завершённой.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 27, 2026
designgoshellbashnodetestinggit

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 7, 2026

npx -y skills add Manshooo/astral-genesis --skill gameplay-testing --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Gameplay Testing?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Gameplay Testing
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/manshooo-gameplay-testing/badge)](https://www.skillsdirectory.com/skills/manshooo-gameplay-testing)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: gameplay-testing
description: Пишет и прогоняет headless-проверки игрового процесса Astral Genesis (генератор уровня, модель тела и распада) плюс ведёт чек-лист ручного плейтеста. Запускать при отладке игровой логики, после правок в GECS-системах, генераторе уровня, дверях/слотах, body-snatch/decay-модели или интеракции, когда нужно покрыть новый инвариант проверкой, и перед тем как считать задачу завершённой.
model: sonnet
effort: high
---

# Тестирование игрового процесса

Отладка игровой логики в этом проекте идёт **через headless-прогон**, а не через
клики в редакторе: у Godot есть консольная сборка
(`Godot_v4.7.2-stable_win64_console.exe`), сценарий в `dev/` запускается
`godot --headless <сцена>` и выходит с ненулевым кодом при провале. Сторонних
тест-фреймворков (GUT, gdUnit) в проекте нет — проверка это обычная сцена Godot
со скриптом, который сам считает ассерты и зовёт `get_tree().quit(код)`.

Скилл делает три вещи: **прогоняет** существующие проверки (§«Быстрый прогон»),
**пишет новые** под новый инвариант (§«Как писать проверку») и держит
**чек-лист ручного плейтеста** для того, что headless поймать не может.

Одно не заменяет другое: headless ловит регрессии в *данных и структуре* (граф
несвязен, характеристика не перенеслась), но не в *ощущениях* (тело село в пол,
дверь развернула камеру).

## Быстрый прогон

```powershell
.\dev\run_checks.ps1
```

Тот же раннер гоняет CI (джоб «Headless-проверки» в `.github/workflows/build.yml`)
на каждом PR в `master` и на каждом пуше в `master`, поэтому он лежит в `dev/`, а не в папке
скилла. Godot он берёт из `-GodotPath`, из переменной окружения `GODOT` или из
`PATH` (на Linux/macOS — `pwsh dev/run_checks.ps1`).

Прогоняет `dev/gen_verifier.tscn` и **любой** `dev/*_check.tscn`, печатает по
каждой `OK`/`FAIL` и общий вердикт с кодом возврата (`0` — всё прошло). Новый
файл с суффиксом `_check.tscn` подхватывается сам, регистрировать его в
скрипте не нужно. Параметры:
- `-Check <имя>` — прогнать только одну проверку: `Gen` (только gen_verifier)
  либо имя файла без расширения (`body_traits_check`, `stat_modifiers_check`,
  …). Без `-Check` — всё сразу.
- `-ListChecks` — только напечатать, что сейчас найдено под `-Check All`, без
  запуска Godot. Полезно после того как в `dev/` появился новый сценарий.
- `-GodotPath <путь>` — если Godot не в `C:\Program Files\Godot Engine\...` по
  умолчанию (скрипт также сам ищет любой `*_console.exe` там же, рекурсивно).
- `-SkipSubmoduleCheck` — не проверять `addons/gecs` перед прогоном.

**Прогон беззвучен, и это сделано специально.** `--headless` выключает
аудиодрайвер Godot, но byProd открывает своё устройство сам и про ключ движка не
знает — пока в `AudioManager` не появилась развилка, каждая проверка играла в
наушниках у того, кто её запустил. В headless рантайм поднимается без устройства
вывода (`create_host_mixed`), и `byprod_check` это сверяет. Признак в логе:
`Audio started on null driver` вместо `on MiniAudio`. Подробности — [[Звук]], §5а.

Скрипт сам чинит одну частую ловушку: **в свежем git worktree `addons/gecs`
пустой** (сабмодуль не инициализирован) — Godot всё равно рапортует `--import`
успешным, но `Entity`/`Component`/`System`/`Observer` не существуют и любой
headless-прогон молча ничего не проверяет. Скрипт вызывает
`git submodule update --init addons/gecs`, если сабмодуль пуст.

## Что именно проверяется

- **`dev/gen_verifier.tscn`** — на 20 сидах: `RS_RoomPresetLibrary.validate()`
  (рассинхрон `slot_count` ↔ реальные двери сцены), достижимость от входа графом
  и дверями отдельно, узлы с больше чем ОДНИМ вертикальным (меняющим глубину или
  этаж) ребром, заваренные двери, тайлы коридоров на этаж и отказы трассы.
  «По дверям» считается так, как ходит игрок: из комнаты в ветку — только через
  дверь на стороне, которую план отдал этой ветке (`RS_LayerPlan.door_sides`), на
  другой этаж или слой — только если в сцене есть портал. Запускать после правок
  в генераторе, раскладке коридоров, `RS_RoomPresetLibrary`, дверях и порталах
  сцен.
  - **Все четыре числа обязаны быть нулём** — недостижимо по графу, по дверям,
    узлов с 2+ вертикальными рёбрами и заваренных дверей. До коридоров
    «недостижимо по дверям» и «заварено» были метриками предела библиотеки
    (подбор искал сцену под степень узла, и лишние двери заваривались); теперь
    рёбер у комнаты ровно столько, сколько дверей в её сцене, и ненулевое число
    значит, что подбор, раскладка и сцена разошлись.
  - **Портал в комнате один** (`LayerStreamer._bind_portals`) — второе вертикальное
    ребро узла было бы оборванным переходом.
  - Итоговую строку «Итог: …» разбирает `dev/run_checks.ps1` — формат
    менять только вместе с ним.
- **`dev/system_order_check.tscn`** — порядок систем в том виде, в каком его
  получает ИГРА: грузит настоящий `world.tscn`, сортирует группы той же
  топосортировкой GECS, что `World.initialize()`, и сверяет: всё, что готовит
  перемещение (скорость, маска), идёт до `S_Movement`, лучи от камеры — после,
  плюс парные `deps()` (гравитация до прыжка, бег и ИИ до шага, детектор цели
  до захвата). Заодно раскладка: папка системы совпадает с её группой,
  наблюдатели лежат в `src/observers/`. Регресс на баг: сортировка по Кану
  уводила `S_Walk`/`S_Jump` за `S_Movement`, и ход с прыжком применялись на тик
  позже, а остальные проверки собирали системы списком и видели другой порядок.
  Проверено мутацией: без `deps()` у `S_Movement` падает ровно на них. Запускать
  после любой правки `deps()`, групп в `world.tscn` или переноса файла системы.
- **`dev/presets_tool_check.tscn`** — вкладка «Редактор пресетов» на ВРЕМЕННОЙ
  библиотеке в `user://` (правки пишутся на диск по-настоящему, и гонять их по
  данным проекта значило бы портить их каждым прогоном): загрузка и счётчик «В
  сцене», правка из карточки доезжает до файла и до строки таблицы, новый тег
  заводится в словаре и вешается, «убрать из словаря» не снимает тег с пресетов,
  проверка сцен разбирает двери. Главное — переименование тега в уже занятое имя:
  это слияние, запись в словаре остаётся одна, пустое описание берётся у старой.
  Мутация «переименовать id без слияния» даёт ровно прежний баг — две записи
  `beta`. Откат Ctrl+Z headless не проверить (истории вне редактора нет); его
  путь проверялся зондом в живом редакторе. Запускать после правок
  `addons/game_design_tool/tabs/presets*`, `shared/undo.gd`, `shared/tags.gd`.
- **`dev/body_traits_check.tscn`** — 24 ассерта модели характеристик тела:
  все сцены тел и шаблон «Тело» грузятся, `E_Player.foot_offset()` реально
  меряет коллайдер (не возвращает 0), характеристики читаются из сцены и
  переносятся при захвате, карман распада тела тикает отдельно от запаса
  души, доброльный выход суммирует остаток, излишек утекает ×3 быстрее,
  гибель тела оставляет `lifespan_death_fraction` и снимает все трейты.
  Запускать после правок в `S_BodySnatch`, `O_ExpelFromBody`, `S_Lifespan`,
  любом `C_BodyTrait`.
  - Предупреждения `WARNING: N ObjectDB instances were leaked at exit` /
    `ERROR: N resources still in use at exit` в конце прогона — фоновый шум
    выключения headless-процесса (тестовые сцены не освобождают всё за собой
    явно), не провал теста. Провал — это код возврата ≠ 0 и строки `FAIL` в
    выводе.
  - **Итоговую строку `=== ИТОГ: ... ===` проверка обязана напечатать сама, и
    раннер её требует.** `--quit-after` считает кадры ОТРИСОВКИ (в headless их в
    разы больше, чем физкадров: `Engine.max_fps` там не соблюдается), и упёршаяся
    в этот лимит проверка закрывается движком, не дойдя до `get_tree().quit()`, —
    то есть с кодом 0. По коду возврата обрыв неотличим от успеха, поэтому
    отсутствие итоговой строки раннер считает провалом.
- **`dev/abilities_check.tscn`** — модель «возможность = компонент» на
  настоящем `e_player.tscn`: полёт и проход сквозь решётки души авторены в её
  сцене, засыпают во плоти и просыпаются при выходе (в том числе после
  пересадки тело→тело и после воплощения ВТОРЫМ путём — загрузкой сейва, без
  захвата); безногое тело не даёт ни прыжка, ни бега, и на попытку отвечает
  сообщением, а призраку — нет; бег кладёт множитель в `C_StatModifiers`, а не
  в `C_Walk.speed`, и снимается вместе с телом; прыжок с настоящего пола
  срабатывает после гравитации; под блокирующим экраном ввод отпускается; HUD
  показывает ровно то, что риг умеет сейчас. Проверка **неявно пишет в
  `user://skills.tres`**: выход из тела, надетого в разделе 6 в обход
  `on_worn` (карман распада пуст), засчитывается «Чистым выходом» и начисляет
  очко. Поэтому `SkillManager.save` на время прогона подменяется копией, а файл
  возвращается байт в байт, и последний ассерт это подтверждает. Запускать
  после правок `O_SoulTraits`, `O_ExpelFromBody`, `S_BodySnatch`, систем
  движения и `UI_HudAbilities`.
- **`dev/stat_modifiers_check.tscn`** — 19 ассертов паттерна «база +
  модификаторы» (`C_StatModifiers`/`RS_StatModifier`): свёртка
  `(base + Σflat) * Πmult`, независимость от порядка источников, идемпотентность
  и снятие источника, изоляция модификаторов между сущностями, доезд дерева
  перков до души. Запускать после правок в `O_ApplySkillEffects`,
  `SkillManager`, любом стате из каталога `C_StatModifiers`.
- **`dev/body_form_check.tscn`** — 44 ассерта физической формы захваченного
  тела (`C_BodyForm`/`O_BodyForm`): контракт сцен тел (корень `E_Body`, слой
  `enemies`, читаемые габарит и маркер `Eyes`), перенос капсулы и посадка
  камеры на уровень глаз, откат к призрачной форме, отказ при нехватке места
  (синтетический блокер) — и отдельно, **на настоящей геометрии трёх комнатных
  шаблонов** (`default_room`/`exit_room`/`lab_room`), что штатно расставленное
  тело (подошва на `y=0`, как и пол комнаты) не считается «нет места» из-за
  простого касания пола. Регресс на баг, найденный живым прогоном: `_fits()`
  кастовал капсулу БЕЗ зазора, и `intersect_shape` против пол-трисетки
  (`ConcavePolygonShape3D`) стабильно засчитывал касание подошвы с полом как
  пересечение — захват отклонялся в КАЖДОЙ заспавненной комнате («Тело здесь
  не поместится»), но не в хабе, где тела оказались случайно приподняты над
  локальным полом. Чинит `_FLOOR_CLEARANCE` в `S_BodySnatch._fits()`.
  Запускать после правок в `S_BodySnatch`, сценах тел, `E_Player.foot_offset`,
  либо в комнатных шаблонах (`src/levels/procedural/rooms/`), если меняется
  расстановка тел или геометрия пола.
- **`dev/enemy_ai_check.tscn`** — 12 ассертов простого поведения врага
  (`S_EnemyAI`/`C_EnemyAI`): бестелесную душу враг не замечает вовсе (цель —
  только воплощённый БФЖ, у которого есть `C_Health`), вне `aggro_range`
  стоит на месте (в т.ч. физически, не только по флагу), в радиусе —
  разворачивается и реально идёт (сквозная проверка, что `E_Enemy` —
  `CharacterBody3D`, а не `StaticBody3D`, как `E_Body` — иначе `S_Walk`/
  `S_Movement` его не двигают), в упор бьёт с кулдауном (не каждый кадр), а
  добитое тело выбрасывает игрока обратно в призрака через уже отлаженный
  `O_ExpelFromBody`. Первые два ассерта — регресс-проверка на баг, найденный
  живым прогоном: враг двигается через `C_EnemyInput`, а НЕ `C_PlayerInput` —
  второй такой компонент в мире ломает добрый десяток мест, которые находят
  «игрока» просто по наличию `C_PlayerInput` (`RunManager.travel_to`,
  `S_InteractionDetector`, HUD), из-за чего переход через дверь и интеракция
  молча промахивались мимо настоящего игрока. Запускать после правок в
  `S_EnemyAI`, `C_EnemyAI`, `E_Enemy`, `C_EnemyInput`/`C_PlayerInput`, либо в
  `C_Health`/`O_ExpelFromBody`, если меняется контракт «кто и как наносит
  урон».
  - Ещё пять ассертов — слои коллизий (всего 17). Игрок стоит на `player`, а не
    на `static_colliders`, враг — на `enemies`, а не на `moving_colliders`. Маска
    врага видит стены, игрока, подвижную и проницаемую геометрию, маска игрока не
    видит `enemies`. И тело за спиной врага остаётся в прицеле захвата: мутация
    «`S_SnatchTargetDetector` не проходит насквозь не-цели» валит этот ассерт.
    Запускать также после правок слоёв или масок в сценах игрока, врага и тел.
- **`dev/movement_check.tscn`** — 5 ассертов перешагивания порогов
  (`S_Movement.STEP_HEIGHT`): CharacterBody3D закатывается на препятствие
  ниже `STEP_HEIGHT` (0.16 м) без прыжка, а настоящая стена выше него
  по-прежнему останавливает как обычно — новый манёвр не должен занижать
  чувствительность коллизий везде подряд. Регресс на баг, найденный живым
  прогоном: дверные пороги и кромки комнатной геометрии высотой в несколько
  сантиметров тормозили ходьбу как полноценная стена, и пройти получалось
  только прыжком. Порог теста взят 0.15 м не случайно — голый
  `move_and_slide()` и без правки перекатывает капсулу через препятствия
  примерно до 0.12–0.13 м просто своей формой, так что тест специально
  целится в сантиметры, которые закрывает именно правка. Пятый ассерт — на
  ВТОРОЙ баг, найденный тем же прогоном: упёршись в настоящую стену, иногда
  не получалось прыгнуть. `_try_step()` при неудачной попытке (стена, не
  ступенька) звал `move_and_collide()` последним — этот метод не обновляет
  `is_on_floor()` (только `move_and_slide()` его считает), и флаг лгал «в
  воздухе» до конца кадра, хотя тело физически на земле; `S_Jump` в
  следующем кадре молча отказывал. Чинит собственный `move_and_slide()` в
  конце `_try_step()` с принудительным маленьким толчком вниз (velocity=ZERO
  снап не считает вовсе) — и отдельная защита от обратного: этот довесок сам
  не должен сжирать прыжок, ЗАДАННЫЙ этим же кадром (`vel.velocity.y > 0` —
  манёвр в process() вовсе не запускается). Запускать после правок в
  `S_Movement`, `S_Jump`, либо если поменялась форма коллайдера тел (радиус
  капсулы меняет естественный «перекат» без правки).
- **`dev/architect_check.tscn`** — 28 ассертов задела под Архитектора
  ([[Артефакт «Архитектор»]]). Прокачка: у `ArchitectManager` своё дерево и свой
  сейв, его улучшения крутят только статы мира (`ArchitectStats`) — стат души у
  Архитектора мёртвая строка, его никто не кладёт в `C_StatModifiers`, — каждый
  стат мира чем-то занят, ранги «Карты комплекса» дают уровни 1–4, лишний ранг
  в данных зажат до максимума, покупка не трогает очки навыков. Тексты: ключи
  дерева и подсказки есть в `ui.csv` (опечатка в ключе показала бы игроку сам
  ключ), счётчик и цена называют эссенцию и склоняются по числу, у навыков
  по-прежнему «очки». Встреча (`A_ArchitectEncounter`): первое касание даёт
  эссенцию и открывает экран ИМЕННО Архитектора, повторное в том же забеге —
  ничего, отметка лежит в сейве забега, новый забег — новая награда, вне забега
  награды нет, а экран есть. Генерация по 30 сидам: Архитектор ровно один, только
  на глубинах 1, 2, 4 и на каждой из них побывал; одна дверь в коридор, без
  портала, не вход и не выход; все глубины вычеркнуты — конфиг невалиден. Сцена:
  одна дверь на юг, артефакт собран как интерактив, пол на пороге двери (иначе
  стык с тайлом — провал, как у B-комнат без коллизии). Сейвы забега, навыков и
  Архитектора подменяются и возвращаются. Проверено мутациями: награда без отметки валит 3
  ассерта, Архитектор без вычеркнутого слоя хаба — ещё 3. Запускать после правок
  `SkillProgression`, `ArchitectManager`, `A_ArchitectEncounter`,
  `data/architect_tree.tres`, записи Архитектора в `data/world_gen_config.tres`,
  `RS_UniqueRoom` и экрана дерева навыков.
- **`dev/complex_map_check.tscn`** — 62 ассерта экрана карты комплекса и мини-карты
  ([[Экран карты комплекса]]). Правило видимости `MapKnowledge` на
  сгенерированном графе: уровень 0 не видит ничего, уровень 1 — не меньше
  мини-карты и по ВСЕМ этажам своего слоя, но без непосещённого за пределами
  соседей; 1–2 держат чужие слои закрытыми, 2 — свой слой целиком, 3 — все слои;
  портал узнаётся ровно у комнат с тегом портала. Экран (`UI_ComplexMap`) на том
  же графе без забега: кнопки слоёв активны по уровню, панель на каждый этаж и
  каждый узел ровно в одной, **обратная проекция курсора** (`node_at_point`) под
  центром комнаты находит её саму, порталы помечены, второй конец межслойного
  портала на уровне 2 скрыт, на 3 — найден, и клик по нему открывает его слой с
  рамкой на нём; содержимое (имя уникальной комнаты, замок с именем ключа,
  значок типа) — только с уровня 4, а заглушки-буквы уникальных комнат разные.
  Настоящий забег: уровень 0 даёт строку «нет связи» вместо экрана, клавиша
  карты (на последнем ранге) открывает экран на паузе с маркером игрока, повторное открытие не
  плодит экран, та же клавиша закрывает и снимает паузу, поверх чужого экрана
  карта не открывается; терминал в хабе — `C_Interactable` без `requires_body`,
  объём на `interactives`, подсветка находит меши его `Visual` (проп `SM_screen`),
  касание открывает экран. Мини-карта (`UI_MiniMap`) на том же забеге, в
  настоящей сцене HUD: на уровне 0 Tab не открывает её, а даёт строку «нет
  связи»; на уровне 2 открывает без паузы и мимо стека экранов, текущий узел
  показан и прорисован раньше соседей, бусины коридора проступают по очереди и
  узел за коридором ждёт, пока до него дойдёт цепочка, новое вписывание
  перетекает, а узнанный при открытой карте узел прорисовывается с этого момента;
  заголовок на ранге 1 — только этаж, со второго — и слой, стрелки лестниц и
  порталов — с третьего; рамка обводит заголовок, план и подсказку и рвётся у
  края; подсказка — «[Tab] Свернуть», мышь не ловит;
  та же клавиша растворяет её, а не прячет сразу. Ниже последнего ранга `M`
  экран не открывает, а отсылает к терминалу, и «[M] Вся карта» в подсказке нет;
  на последнем — есть, и полная карта сворачивает мини-карту; поверх экрана Tab
  её не трогает; терминал открывает экран уже на первом ранге. Плюс действия `map` и `map_mini` в `InputMap` и
  `REBINDABLE_ACTIONS` без конфликта клавиш и перевод всех ключей карты.
  Сейвы забега и Архитектора подменяются и возвращаются.
  Запускать после правок `MapKnowledge`, `UI_MapFloor`, `UI_ComplexMap`,
  `UIManager.open_complex_map` и `_toggle_mini_map`, `UI_MiniMap`,
  `ArchitectManager.map_level`, терминала и
  `hub.tscn`, записей `map_name`/`map_icon` в конфиге генерации и каталоге типов.
- **`dev/corridor_graph_check.tscn`** — 23 ассерта генерации графа с коридорами
  ([[Процедурные коридоры между комнатами]]). По 30 сидам: граф связен от входа;
  вход — хаб, выходы — нужное число, на своей глубине, со своей сценой и тегом;
  **у комнаты рёбер в коридоры ровно столько, сколько дверей в сцене** (ребро без
  двери — недостижимый сосед, дверь без ребра — заваренная дверь), и ведут они в
  коридоры её этажа (в какие — решает раскладка, комната бывает на двух коридорах,
  см. `corridor_layout_check`); у каждого коридора есть сосед (коридор без дверей —
  законная перемычка или тупик); не больше одного
  вертикального ребра на узел, и портал в СЦЕНЕ (ищется `E_VerticalPortal`, а не
  тег пресета) ровно тогда, когда вертикальное ребро есть; каждая пара слоёв
  связана ОТКРЫТЫМ переходом, каждая пара этажей — переходом. Плюс детерминизм,
  `HOME_DEPTH` (умолчание тула) равен глубине хаба из конфига, уникальная
  комната с шансом 0 и 1 (роль будущего Архитектора), снимок ручек и версии
  генератора в сейве, а забег прежней версии сбрасывается на вход со свежим
  снимком и нетронутым запасом — сейв подменяется только в памяти. Проверено мутациями: не раздать лишние
  двери, снять гарантию открытого перехода, убрать сверку портала — каждая валит
  свой ассерт. Запускать после правок `RS_LevelGraph`, `select_preset`,
  `RS_WorldGenConfig`/`RS_UniqueRoom`, `data/world_gen_config.tres` и при
  добавлении пресета с порталом.
  - **Здесь впервые поймали «тихо оборванный блок»**: ошибка скрипта
    (`SCRIPT ERROR`) внутри блока обрывает только этот блок, итоговая строка
    печатается с нулём провалов, и первый прогон этой проверки прошёл зелёным
    на 6 ассертах из 23. Сначала от этого защищались ручным счётом ассертов;
    теперь это сторож общей обвязки (`dev/check_harness.gd`) для всех проверок.
- **`dev/corridor_spawn_check.tscn`** — 23 ассерта сборки коридоров в мире:
  настоящий `RunManager`. На каждый тайл плана — ровно один кусок кита, под маску
  своего тайла и своей ветки; тупики слоя встали куском тупика и они есть (иначе
  лучи ниже тупика бы не видели); **проёмы и стены сверяются ЛУЧАМИ по коллизии**
  (слой `static_colliders`): в открытую сторону луч проходит, в закрытую
  упирается — только так ловится перевёрнутый поворот
  (`RS_CorridorKit.rotate_mask`), ассерт «кусок под маску» зовёт ту же формулу и
  на мутации зеленеет (мутация «тупик с неверной базовой маской» валит лучи). На
  стыке тайла с комнатой под ногами пол. Двери привязаны по сторонам плана, заваренных нет;
  межэтажный переход — порталом. Дверь в коридор открывается на месте: игрок и
  узел не меняются, подсветка гаснет, а **проём освобождается по коллизии** —
  луч сквозь дверь до открытия упирается в полотно, после — нет. По высоте узла
  это не проверить: прежний ассерт смотрел на `Visual` и зеленел, пока меш
  уезжал вверх, а коллизия полотна оставалась в проёме (мутация «поднимать
  Visual» его валит). Дверь хаба открывается на месте, как любая, шаг за неё —
  в его коридоре, и присутствие делает коридор текущим.
  Мини-карта (`UI_MiniMap.build_view`): текущая ветка и хаб известны, комнаты на
  ветке видны соседями, маркер игрока внутри карты. Загрузка сейва в коридоре —
  на тайл той же ветки и по снимку ручек, даже если база ручек с тех пор
  изменилась. Сейв разработчика бэкапится. Строка «арт» в выводе — НЕ провал: список комнат, чья
  геометрия заходит за грань клетки (двери B-комнат на 9.3–10 м, решено чинить
  в арте); опустеет — превратить в ассерт. Запускать после правок `RunManager`
  (спавн тайлов, `use_door`, привязка дверей и порталов), `RS_CorridorKit`,
  `data/corridor_kit.tres`, сцен тайлов и дверей, `mini_map.gd`.
- **`dev/corridor_layout_check.tscn`** — 34 ассерта коридорной РАСКЛАДКИ
  (`RS_CorridorPlanner`, сетью — п. 7 карточки «Сетка уровня», коридор на отрезок
  между развилками — карточка «Коридор — отдельный узел») — второй путь
  валидации: gen_verifier сверяет двери по сцене, а у коридора сцены нет, и без
  проверки по плану он просто выпал бы из проверок. Два прогона — 30 сидов на
  ручках по умолчанию и 10 на 8 комнатах на этаж: все трассы проложены; комнаты
  не делят клетку, тайлы не встают на комнаты; двери — ровно двери сцены или
  сокеты, коридоры — ровно рёбра графа; перед каждой дверью тайл её коридора с
  проёмом в комнату; проёмы взаимны и ни один не ведёт в пустоту; стык коридоров
  есть ровно там, где в графе ребро между ними (лишний стык — проход, которого в
  графе нет); тайлы коридора связны; коридор — путь без развилок внутри и на
  развилке не сворачивает; коридор без двери — перемычка или тупик; тайл с одним
  проёмом — ровно отмеченный тупик, и тупиков на этаже не больше ручки; `node_at`
  узнаёт и комнаты, и тайлы; обволакиваемых комнат не больше 1 %; комнаты на двух
  коридорах, петли по коридору и тупики есть. Плюс детерминизм «один сид — один
  граф и раскладка». Печатает метрики раскладки (`RS_LayoutMetrics`, с числом
  коридоров на этаж) и распределение кусков кита — смотреть на них при правке
  `TURN_COST` и ручек петель/тупиков. Проверено мутациями: не открыть проём в
  комнату, не открыть стык — каждая валит свои ассерты; раскладка без
  предохранителей от обволакивания проходит на 4 комнатах и валится на 8 (15 %);
  с 29.09 8 комнат — ручки по умолчанию, а второй прогон идёт на 12, потолке
  ручки; развилка, берущая в коридор двух
  любых соседей вместо прямых, валит форму коридора.
- **`dev/room_presence_check.tscn`** — 17 ассертов «текущий узел меняется
  присутствием, а не дверью». На плане по 10 сидам: каждая точка комнаты —
  центр, угол, рост игрока, чуть ниже пола — находит свой узел через
  `RS_LayerPlan.node_at`, этажом выше та же (x, z) — уже не он, пустота и провал —
  ничьи. Сквозной прогон настоящего `RunManager` с `S_RoomPresence`: игрок
  шагает из хаба на тайл его ветки, и узел меняет НЕ шаг, а следующий такт
  системы — ровно один `room_changed`, ветка в сейве текущая и посещённая;
  повторный такт тишина, пустота узел не трогает, возврат пешком возвращает
  узел, переход на другой слой меняет узел сразу. Что дверь в коридор никого не
  переносит, сверяет `corridor_spawn_check`. Сейв разработчика бэкапится и
  возвращается, как в `run_stats_check`. Запускать после правок `travel_to`,
  `note_presence`, `RS_LayerPlan` (сетка, этажи).
- **`dev/world_gen_tool_check.tscn`** — 2500+ ассертов по 30 сидам: слой
  редакторского тула «Генератор мира» (`addons/game_design_tool/world/`)
  строится по числу узлов графа (оверлеи «Геометрия» и «Граф» не оставляют
  геометрию прошлой раскладки, «Подписи» — не остаются видимыми БЕЗ
  выделения), а ray-vs-AABB пикинг (`Picker`) находит именно тот узел, в чью
  коробку послан луч. Всё в этой области ломается ТИХО — неверный пикинг,
  утёкшая геометрия предыдущей раскладки, обводка не на той комнате или
  подпись не с тем текстом/не в том месте не бросают ошибку, просто
  показывают не то, что нарисовано. Регресс на баг живого прогона: оверлеи
  чистили детей `queue_free()`, а пересборка слоя синхронная (сид/глубина
  меняются в один тик) — отложенное удаление не успевало сработать между
  вызовами `show_layer`, и старая геометрия накапливалась поверх новой. Чинит
  `free()` вместо `queue_free()` в
  `RoomsOverlay.clear()`/`GraphOverlay._clear_spheres()`. Плюс: 9 ассертов на
  инлайн-редактор пресета в боковой панели (`tabs/world_gen.gd`, v3-стретч);
  `LabelsOverlay.set_selected` — подпись скрыта до выделения, при выделении
  показывает id/имя пресета и встаёт на +5 м от верхней грани
  `Picker.room_aabb`, прячется при сбросе; `RoomsOverlay.set_selected` —
  `material_overlay` (обводка, тот же приём, что у игрового
  `O_OutlineVisual`/`RS_EntityVisuals`) ставится РОВНО на геометрию выбранной
  комнаты и снимается с прежней при переключении/сбросе. Всё — ТОЛЬКО чтение:
  узел с пресетом заполняет форму/подпись значениями ИЗ ресурса, узел без
  пресета (хаб — авторская сцена вне библиотеки) прячет секцию инлайн-
  редактора вместо пустой/чужой формы. Обработчики правки
  (`_on_slot_changed` и т.п.) не зовутся — пишут в реальные файлы проекта, та
  же дисциплина, что у кнопок Room Wizard. Плюс `_check_restore_state` —
  `_restore_state()` (реальный путь первого показа вкладки) вне редактора
  безопасно откатывается на дефолты сида/глубины/оверлеев и всё равно строит
  граф, а не падает на `EditorSettings`, которого в headless-прогоне нет
  (`Engine.is_editor_hint()` ложно, см. `world_gen.gd._get_meta`/`_set_meta`);
  само чтение/запись project metadata и живое сохранение позиции камеры
  headless не проверяются — `EditorInterface.get_editor_settings()` этот
  прогон не задевает, звать его некуда. Плюс на каждом из 30 сидов: хаб
  (`RS_LevelGraph.HUB_ROOM_SCENE`) — ровно один узел на весь граф, и это
  всегда гарантированный тупик (degree=1) — регресс-щит на риск того, что
  `hub.tres` (`RS_RoomPresetLibrary.hub`, у хаба тоже есть свой пресет теперь,
  см. [[Единый редактор геймдизайна]]) протечёт в пул автоподбора `.presets` и
  молча достанется другим узлам-тупикам; и что боковая панель узнаёт хаб как
  обычный пресет (секция видима, `_editing_preset == library.hub`), а не
  прячет её, как раньше. Запускать после правок в
  `RS_LayerPlan`, `RS_RoomLayout`, `RS_LevelGraph`, `RS_EntityVisuals`,
  `RS_RoomPresetLibrary`, либо в самом `addons/game_design_tool/world/` или
  `tabs/world_gen.gd` (живую камеру, клики `MenuButton` «Видимость» и
  `EditorInterface`-кнопки этот прогон не задевает — их некуда звать headless).
  Коридоры (`_check_corridors`): оверлей «Коридоры» рисует каждую ветку,
  «Геометрия» ставит по куску кита на тайл (тем же `RS_CorridorKit.instantiate`,
  что игра) и после второй сборки подряд не оставляет тайлов первой — мутация
  «не освобождать тайлы в clear()» ассерт валит; выделенная ветка обводится
  всеми тайлами и снимается при сбросе; клик по тайлу выделяет ветку, по комнате
  — комнату. Сетка (`_check_grid_overlay`, 5 сидов, все слои): контур и стрелка
  поворота у каждой комнаты, дверных отметок ровно дверей плана, глухих —
  остальных сокетов, тупики и стыки веток по плану, пересборка не копит отрезки,
  выделение красит ровно контур и стрелку своей комнаты. Стрелка сверяется с
  поворотом корня (`room_transform`) — формулой, которой оверлей не пользуется;
  мутация «+turns вместо −turns» её валит. Прогон сидов (`_check_seed_sweep`):
  метрики раскладки совпадают с посчитанными по тем же сидам, а таблица «Где
  встретить» (в скольких сидах пресет выпал, по слоям) пересчитывается прямо по
  сидам без кода вкладки — общий с ней только формат строки; мутация «слою
  засчитывается два сида» её валит.
- **`dev/grid_view_check.tscn`** — 10 ассертов узла сетки `GridView` и грани
  клетки во вложении (`GridEmbedding.face_edge`), без плана и без игры: грань —
  на полпути к соседу, длиной в клетку, поперёк; решётка 3×2 — 17 отрезков (общая
  грань один раз, без склейки было бы 24); контур — грани периметра; стрелка
  смотрит к соседу за своей стороной, стрелка грани — с её середины внутрь;
  отметки по цветам; `commit` собирает один меш линий, пустой узел — без меша.
  Ломается это молча — схема просто рисует не то. Запускать после правок
  `src/grid/grid_view.gd` и вложений сетки.
- **`dev/room_wizard_check.tscn`** — 35 ассертов: Room Wizard
  (`addons/game_design_tool/dock/room_wizard.gd`) на пяти реальных сценах
  комнат считает верный путь ресурса (соглашение имён — `<сцена>.tres` рядом
  со сценой) и строит рефлексивную форму РОВНО по скалярным `@export`-полям
  `RS_RoomPreset` (`display_name`/`slot_count`/`weight`) — без `scene`/`tags`
  (своя вёрстка) и без служебных полей `Resource` вроде
  `resource_local_to_scene`, которые тоже несут `PROPERTY_USAGE_EDITOR` и
  просочились бы в форму при недостаточно строгом фильтре
  (`PROPERTY_USAGE_SCRIPT_VARIABLE and PROPERTY_USAGE_EDITOR` вместе — только
  одного `EDITOR` мало). Клики по кнопкам «Сохранить»/«Добавить в библиотеку»
  не проверяются вовсе — они пишут в реальные файлы проекта (новый `.tres`,
  правка `room_preset_library.tres`), и гонять запись на каждый прогон было
  бы разрушительно, а не проверкой; их поведение — живой прогон в редакторе.
  Исключение — `.disabled`-СОСТОЯНИЕ кнопки «Добавить в библиотеку» на хабе
  (`hub.tscn`): это чтение, не запись, и оно ровно то, что защищает инвариант
  «хаб не в пуле автоподбора» (`RS_RoomPresetLibrary.hub`, не `.presets`) от
  случайного клика дизайнера — единственная сцена в наборе, где эта кнопка
  обязана быть выключена, хотя пресет уже существует на диске. Запускать
  после правок в `RS_RoomPreset`, `room_wizard.gd`, `RS_RoomPresetLibrary`,
  либо при переносе/переименовании сцен комнат (ломает соглашение имён, на
  которое Wizard опирается).
- **`dev/room_types_check.tscn`** — 18 ассертов ВТОРОЙ оси подбора комнат
  (`RS_RoomTypeCatalog`, `RS_RoomPreset.room_type`, `RS_LevelNode.room_type`):
  каталог валиден и каждый его тип достижим хотя бы на одном слое `DEPTHS`;
  выпавший тип всегда покрывает глубину узла, а на глубине без подходящих типов
  выпадает «без типа»; **бросок тратит rng одинаково при любом исходе** (rng
  один на всю генерацию — пропуск броска сдвинул бы всё, что разыгрывается
  после, и граф перестал бы быть функцией сида); тип — предпочтение, а не
  фильтр (тип без единого пресета не оставляет узел без комнаты) и при этом не
  пробивает фильтр портала, а лишний тег пресета не отнимает у него узел своего
  типа (специфичности, из-за которой так пропадала `lab_room`, больше нет); после
  генерации `room_type` узла равен типу ФАКТИЧЕСКИ вставшей комнаты, ключи типов
  не протекли в `tags`, типы детерминированы по сиду, а домашний узел подписан
  типом хаба. На ветке без коридоров (прежний генератор) ассерт «типизированные
  комнаты встречаются» не проходил — прежний подбор их вытеснял. Библиотека для части ассертов собирается прямо в проверке из
  настоящих сцен: поведение подбора нужно поставить в заранее известные условия,
  а на живой библиотеке они разъедутся при первой новой комнате. Запускать после
  правок в `RS_RoomPresetLibrary`, `RS_LevelGraph`, каталоге типов или при
  добавлении пресета с `room_type`.
  - Проверка проверена мутациями: снятие предпочтения по типу валит 3 ассерта,
    отказ переписывать тип фактическим — ещё один. Держать её такой и дальше:
    оси тегов и типа разъезжаются молча, ошибкой это не выглядит никогда.
- **`dev/outline_mask_check.tscn`** — 30 ассертов обводки силуэтом
  (`O_OutlineVisual` + `S_OutlineMask`). Пикселей headless не рисует, поэтому
  проверяется ВХОД постэффекта, и ломается там всё тихо: ошибка в бите слоя, в
  `cull_mask` камеры-двойника или в фоне её окружения не бросает ничего — просто
  вместо контура вокруг объекта выходит либо пустой экран, либо рамка по краю
  кадра. Слой: выделение переводит на слой обводки ВСЕ меши объекта (иначе
  силуэт дырявый), прочие слои меша не тронуты, снятие возвращает их ровно к
  прежним, а `material_overlay` не трогается вовсе — регресс-щит на тихий возврат
  старого подхода с выдавленной геометрией. Сборка маски: `cull_mask` ровно один
  бит, фон пустой а не небо (с небом маска залита целиком), `mask_tex` подан в
  шейдер, материал оверлея — КОПИЯ, а не общий `.tres` (иначе `ViewportTexture`
  прогона утекает в файл проекта). Цена: без выделения маска в `UPDATE_DISABLED`
  и оверлей скрыт — полноэкранный проход не должен крутиться вхолостую.
  Геометрия: камера-двойник совпала с игровой по трансформу и проекции, размер
  маски равен экрану (шейдер читает её по `SCREEN_UV`), ножницы накрывают
  проекцию объекта и заметно меньше экрана, объект за спиной обводки не даёт, а
  объект в упор обводится по всему экрану (`unproject_position` там врёт).
  Проверено мутациями: снятие ножниц валит ассерт про площадь, покраска только
  первого меша — ассерт про составной объект. Запускать после правок в
  `O_OutlineVisual`, `S_OutlineMask`, `RS_EntityVisuals`, `outline_mask.gdshader`
  либо при смене слоя обводки. Как контур ВЫГЛЯДИТ — только живой прогон.
- **`dev/room_tags_check.tscn`** — 19 ассертов СЛОВАРЯ структурных тегов
  (`RS_RoomTagCatalog`, `data/room_tag_catalog.tres`): словарь назначен
  библиотеке и валиден (нет дублей и пустых `id`); каждый тег, который носит хоть
  один пресет, в словаре есть и **описан** (тег без описания неотличим от
  опечатки — ради этого различия словарь и заводился, и только оно делает
  `verticalhub` заметным рядом с `vertical_hub`); флаг `set_by_generator` совпадает
  с реальностью **в обе стороны** — каждый тег, который `RS_LevelGraph` за 12
  сидов проставил узлам, помечен флагом, и каждый помеченный действительно
  выпадает. Вторая сверка не педантизм: по флагу инструмент выключает
  «Переименовать», и соврав в любую сторону, он либо блокирует безопасную правку,
  либо разрешает ту, что молча сломает подбор (ключи тегов в графе — строковые
  литералы). Обратного требования нет: тег в словаре, который пока никто не
  носит, — нормальное состояние (завели под ещё не нарисованную комнату).
  Запускать после правок в словаре, `RS_LevelGraph` (особенно если появился новый
  тег узла) или при добавлении пресета с новым тегом.
- **`dev/footsteps_check.tscn`** — 13 ассертов озвучивания ходьбы
  (`S_Footsteps`/`C_Footsteps`). Разовый звук (`PER_STEP`): идущее по земле тело
  просит звук, путь меряется ПУТЁМ а не временем (число шагов отвечает
  пройденным метрам, на удвоенной скорости шагов заметно больше), стоящий и
  висящий в воздухе молчат, событие берётся из компонента, а сущность без
  `C_Footsteps` в выборку не попадает. Петля (`LOOP`): на весь отрезок ходьбы
  ровно одна просьба сыграть, а не одна на шаг — зацикленное событие не
  кончается само, и «на каждый шаг» копило бы инстансы, пока не кончатся
  голоса; остановка снимает петлю и не просит звук заново, следующий отрезок
  заводит её снова.
  Наблюдается сигнал `AudioManager.event_requested` — он шлётся ДО проверки на
  загруженный проект byProd, поэтому проверка работает и без звукового
  контента. Тело — настоящий `E_Enemy` на настоящем полу с
  цепочкой S_Gravity → S_Movement: `is_on_floor()` считает только
  `move_and_slide()`, и на синтетической сущности без пола он всегда ложь —
  проверка молча мерила бы пустоту. Запускать после правок в `S_Footsteps`,
  `C_Footsteps`, `AudioManager`, либо если менялась скорость/стоп хода
  (`S_Walk`, `C_Walk`) — частота шагов следует фактической скорости.
- **`dev/byprod_check.tscn`** — 23 ассерта подключения аудиодвижка byProd
  (GDExtension `addons/byprod`): пять классов расширения зарегистрированы,
  менеджер создаётся, версия рантайма совпадает с той, под которую написан
  биндинг, запрос несуществующего события даёт `null`, такт и слушатель
  безопасны, а событие шагов (`event:/sfx/walk`) находится, заводится и
  переходит в `STATE_PLAYING`. Последнее проверяется именно так, а не по флагу
  «проект загружен»: банки рантайм просит у хоста сам, и промах по каталогу
  выглядит снаружи ровно как «нет такого события». Отдельно — **что прогон не
  открывает устройство вывода**: `--headless` глушит аудиодрайвер Godot, но не
  byProd, и без развилки в `AudioManager` проверки звучат в наушниках у того,
  кто их запустил. Ассерт честно слабый — спросить у рантайма «открыто ли
  устройство» нечем, поэтому сверяется решение `AudioManager`; ловит он ровно
  убранную развилку, после которой звук вернулся бы молча. Отдельный ассерт сверяет
  проект с МЕХАНИКОЙ: событие, которое просит карточка навыка
  (`UI_SkillNode.unlock_event`, `@export`-строка), в собранном проекте есть.
  Спрашивается строка с карточки, а не литерал рядом — сверка константы с самой
  собой зеленела бы всегда; расходятся здесь именно проект и то, что просит код,
  и расходятся молча: byProd один раз предупредит в лог, а экран станет
  беззвучным. Ровно так этот путь и жил, пока события в проекте не было. Отдельным блоком — громкость:
  значение из настроек доезжает до мастер-шины, ноль доезжает как ноль (а не
  теряется по дороге), значение вне диапазона прижимается к единице. Правится
  оно через `SettingsManager.settings` целиком, потому что проверяется вся
  цепочка «настройки → сигнал → движок», а не вызов в обход неё; исходные
  настройки проверка возвращает на место. Запускать после обновления
  расширения из [godot-byprod](https://github.com/Manshooo/godot-byprod), замены
  SDK, пересборки проекта звука в редакторе byProd, правок громкости
  (`RS_Settings.master_volume`, `AudioManager._apply_volume`) или паузы
  (`AudioManager._apply_tick_level`, `_follow_pause`).
  - **Пауза** (3 ассерта): биндинг умеет `set_auto_pause` — единственный ассерт
    про паузу, который проходит и без рантайма, и ровно он ловит отставший
    аддон в `addons/byprod/bin/`; а на живом рантайме инстанс, выданный
    `AudioManager`, уходит на паузе игры в `STATE_PAUSED` и возвращается в
    `STATE_PLAYING` после. Второе проверяется наблюдаемым состоянием голоса, а
    не вызовами, потому что ломается это тихо во все три стороны сразу
    (неподписанный инстанс, автолоад без `PROCESS_MODE_ALWAYS`, уровень тика,
    не вернувшийся к FULL) и выглядит одинаково — «звук ведёт себя не так», без
    ошибки в логе. Мутациями проверено: снятие подписки и возврат автолоада в
    `PAUSABLE` валят ассерт каждый по отдельности. Инстанс берётся у
    `AudioManager`, а не у описания напрямую: подписка ставится там, и мимо неё
    проверялся бы путь, которым механика звук не заводит.
  - **Нативный рантайм (`byProd.dll`/`libbyProd.so`) в репозитории не лежит** —
    лицензия Madrigal разрешает распространять его только внутри собранной игры.
    Его отсутствие не провал: проверка это печатает и переключается на ветку
    «деградация в no-op» (10 ассертов вместо 21). Провал — если без рантайма
    что-то падает или `get_last_error()` молчит.
  - **В свежем клоне/worktree проверка провалится, пока не выполнен импорт**
    (`bash dev/import.sh` — в облачной сессии его уже сделал SessionStart-хук;
    голый `godot --headless --path . --import` в headless виснет на
    многопоточном импорте, см. [[Релизы и сборка]]): список расширений лежит в
    `.godot/extension_list.cfg`, который в git не входит, а обычный запуск сцены
    его не создаёт — классов просто не существует. Та же ловушка по духу, что и
    с пустым `addons/gecs`.
  - Ни один класс расширения не назван в скрипте идентификатором, только строкой
    через `ClassDB`: иначе при незагруженном расширении скрипт не прошёл бы
    парсер, `_ready()` не запустился бы, и **прогон завершился бы с нулём**,
    пропустив ровно ту поломку, ради которой написан.
  - **Менеджер берётся у `AudioManager`, свой заводить нельзя**: второй
    `ByProdSoundManager` в одном процессе роняет рантайм segfault'ом прямо в
    создании, когда устройство уже открыто первым. Проверка, создававшая
    собственный, валила прогон кодом 139 — при том, что все её ассерты успевали
    напечататься зелёными, и в выводе поломка выглядела как чужая.
- **`dev/settings_menu_check.tscn`** — 24 ассерта вкладочного меню настроек
  (`src/ui/settings_menu`): состав и порядок вкладок, открыта первая, каждая
  настройка на своей вкладке и ни одна не задвоена. Проверка нужна ровно
  потому, что меню собирает контролы **рекурсивным обходом** поддерева, а не по
  путям: настройка, потерянная или вынесенная мимо вкладок при переверстке, не
  даёт ни ошибки, ни предупреждения — она просто перестаёт быть в меню.
  Отдельно два тихих инварианта раскладки: значения читаются и со **скрытых**
  вкладок (иначе baseline снимался бы неполным и «Применить» горела бы сразу
  при открытии — это и проверяется по кнопке), и минимум `TabContainer` **не
  равен** минимуму самой длинной страницы (`ScrollContainer` внутри вкладки
  рвёт эту связь, иначе «Управление» с раскладкой клавиш растягивала бы окно и
  на «Аудио» с одним ползунком). Меню инстанцируется напрямую, без
  `UIManager`; настройки игрока не трогаются — правится только черновик,
  «Применить» не нажимается. Запускать после любой переверстки меню настроек,
  добавления настройки в `RS_Settings` с контролом и правок
  `settings_menu.gd`/`src/ui/settings/*`. Чего проверка **не** видит: как это
  выглядит — стили живут в `assets/ui/default_theme.tres`, эхо и штрих рисуют
  `UI_EchoButton`/`UI_SettingRow`, и headless их не рисует. Зато видит
  поведение: кнопки-вкладки листают страницы в своём порядке, без теней строка
  разрешения теней гаснет, строка наполняет колонку подсказки.
- **`dev/skill_graph_check.tscn`** — 29 ассертов дерева навыков БЕЗ единого
  `Control`: радиальная раскладка `SkillGraphLayout` (место у каждого навыка,
  нейроны не налезают друг на друга, требование ближе к ядру, чем зависимый,
  нейрон в секторе своей ветки, секторы в порядке `ordered_branch_ids`),
  `graph_row` как слот по дуге, ветка по сумме рангов дальше от ядра, кольцо
  требований и обе развилки видимости — `is_revealed` («навык можно брать») и
  `is_previewed` («шаг вперёд пунктиром»). Запускать после правок
  `SkillManager`, раскладки сети и `data/skill_tree.tres`.
- **`dev/skill_card_check.tscn`** — 13 ассертов экрана навыков вокруг карточки
  справа: карточка не уходит за низ экрана и не шире колонки на каждом навыке
  боевого дерева, по-русски и по-английски (длинная причина на кнопке —
  «Сначала выполните требования» — уже раз распирала колонку; кнопка поэтому
  сдвинута на 26 px влево, как в спеке); кнопка называет ту причину, что есть
  (освоено / требования / нет очков), а не первую попавшуюся; вкладка
  Архитектора не видна до встречи с ним и видна после — в том числе у
  инкубатора; эссенция с тысячи — «12,4 тыс.»; старый сейв с рангами
  Архитектора считается встречей. Покупку не зовёт — сейвы подменены и не
  пишутся. Запускать после правок `UI_SkillCard`, `SkillTreeUI`, текстов
  навыков и при добавлении навыка с длинным описанием.
- **`dev/skill_layers_check.tscn`** — 13 ассертов слоёв нейросети на боевом
  дереве и на синтетическом с заведомо пустой веткой: подпись ветки видна ровно
  там, где у ветки есть показанные нейроны, берётся из данных и не накрывает
  нейроны; синапс есть на каждое показанное требование `SKILL_RANK` и на каждый
  корень (от ядра) — ни одного лишнего; синапс упирается в края нейронов, а не в
  центры; синапсы лежат под нейронами (своего `z_index` нет, держит порядок в
  полотне). Запускать после правок `UI_SkillSynapse`, `UI_SkillGraph` и
  подписей веток.
- **`dev/skill_economy_check.tscn`** — 28 ассертов экономики очков: монотонность
  `RunManager._max_depth_reached`, порог 50% выжатости тела для «Чистого
  выхода», покрытие каталога статов навыками и сброс дерева на «Новую игру»
  (включая то, что покупка навыка не пишет в дефолтный сейв — `duplicate()` не
  копирует `Dictionary`). Реально зовёт `add_skill_points`/`unlock`, то есть
  ПИШЕТ на диск, поэтому оригинальный `SkillManager.save` возвращается и
  пересохраняется в конце.
- **`dev/debug_overlay_check.tscn`** — 24 ассерта отладочного оверлея
  (`dev/debug_overlay.gd`, читы для живого прогона — см. [[Отладочный оверлей]]):
  путь оверлея в `main.gd` ведёт на существующую сцену (опечатка не даёт ни
  ошибки, ни предупреждения — `ResourceLoader.exists()` вернёт `false`, и читов
  просто не будет), **каждый пресет экспорта исключает `dev/*`** — единственное,
  что держит читы вне собранной игры, а фильтр правится в диалоге экспорта одним
  кликом; шпаргалка на экране соответствует таблице читов (строка на каждый, ни
  одной пустой); клавиши не повторяются и не пересекаются с `InputMap` (до
  второй записи на ту же клавишу перебор не доходит, и чит выглядит «не
  работает»; этот ассерт сразу и нашёл F5, занятый встроенным
  `ui_filedialog_refresh`); «слой ниже» ведёт вглубь — сверка идёт с самим
  `RS_LevelGraph`, а не с числом в проверке; бессмертие держит полными ОБА
  кармана распада, и перед этим отдельный ассерт требует, чтобы без чита карман
  убывал — иначе «не убыло» было бы правдой зря и проверка зеленела бы на
  сломанном `S_Lifespan`. Клавиши не подставляются в обход обработчика:
  синтезируется `InputEventKey` и зовётся `_unhandled_key_input`, то есть
  проходится тот же путь, что при живом нажатии. Сейвы `SkillManager` и
  `ArchitectManager` подменяются и возвращаются на место — начисление очков и
  эссенции ПИШЕТ в `user://`. Четыре ассерта — раздел «Прокачка» меню отладки:
  кнопка на каждую запись таблицы начислений оверлея с её подписью, очки ложатся
  только `SkillManager`, эссенция только `ArchitectManager` (валюта в чужом
  дереве не падает — экран Архитектора просто пуст), меню после начисления не
  закрывается. Семь — меню отладки (`dev/debug_menu.tscn`, клавиша `` ` ``) и его
  «Телепорт»: меню открывается через стек `UIManager` и не открывается дважды,
  кнопка на каждую запись `unique_rooms`, поиск по `room_scene_path` на пяти
  настоящих сидах (мутация «искать по другой строке» его валит), круг по комнатам
  одной сцены, кнопка без забега. Пересечение с `InputMap` не засчитывает
  действия с модификатором (Ctrl+`` ` `` у `ui_swap_input_direction`): голая
  клавиша их не вызывает, мутация «чит на F5» по-прежнему ловится. Сам
  `travel_to` здесь не зовётся — ему нужен живой мир. Запускать после правок
  таблицы читов и начислений, меню, `main.gd`, фильтров экспорта и
  `unique_rooms` в конфиге генерации.
- **`dev/hud_check.tscn`** — 89 ассертов HUD «Отголосок» без пикселей
  ([[HUD — спека]]). Кривые эффектов экрана (`UI_HudScreenFx.update_params`)
  сверяются с таблицами спеки по каждой строке: распад души, распад тела (без
  базы, в сепию, распад души не протекает в тон тела), кровь по HP — опечатка в
  пороге не роняет ничего, экран просто давит не тогда. Удар и волна вселения —
  импульсы, которые гаснут; цвет волны — нового кармана; красная вспышка растёт
  с долей урона (царапина — слабо, но не ноль); каждый удар кладёт новую
  раскладку пятен крови и перетекает в неё с прежней, а не рывком. Дуга (`UI_DecayArc`):
  ровное убывание её гасит, скачок на 0.05 за полсекунды зажигает, пороги 35 % и
  15 %, излишек держит видимой, во плоти она показывает карман тела даже при
  пустой душе. Смятение T от числа тел в `RunStats`. Сообщение: таймлайн и
  равенство `C_ScreenMessage.remaining` его длине, перенос после 720 px, резкость
  посередине показа и размытие на растворении.
  Подсказка — «[клавиша] действие», тень шире строки. Имена кнопок мыши и
  пробела — ключами. **Каждый ключ `HUD_*`/`KEY_*`/`ITEM_*`, названный
  литералом в `src/`, есть в `ui.csv` на ru и en** (опечатка показала бы игроку
  сам ключ). В сцене HUD нет ни одной подложки и полосы, эффекты экрана лежат
  ниже обводки, вариации `Hud*` темы (и `HudMap*` мини-карты) — на Golos;
  перо мини-карты (`UI_MiniMap._partial`) обрывает контур по доле длины пути, а
  не по доле точек. Время проверка подаёт сама
  (`update_state`/`update_params` принимают `t`), кадры движка не нужны.
  Раздел HUD в `abilities_check` — строки управления на смене тела и сообщение
  отказа на настоящем мире. Запускать после правок `src/ui/hud/`, темы,
  `ui.csv`, `C_ScreenMessage` и подсказок дверей в `LayerStreamer`.
- **`dev/version_label_check.tscn`** — 3 ассерта: номер сборки виден там, где
  его ищут по скриншоту бага, — в углу главного меню и в строке состояния
  отладочного оверлея — и это ровно `application/config/version`. На время
  прогона подставляется номер не-релизной сборки `0.7.0-dev.33+fc91d3c`, а не
  тот, что в `project.godot`: показывать надо именно его, с `+` и точками как
  есть ([[Релизы и сборка]], «Номер сборки»). Оверлею нужно два кадра:
  `process_frame` приходит раньше `_process` узлов, и после одного ожидания в
  строке ещё «—». Запускать после правок главного меню и строки состояния
  оверлея.
- **`dev/preset_stats.gd`** (статистика подбора пресетов — какой пресет
  сколько раз выбран, распределение степеней узлов) **не входит в
  `dev/run_checks.ps1`**: имя не оканчивается на `_check`, и это
  осознанно — он выполняется через `godot --script`, где автолоады не
  существуют, поэтому `Entity` не компилируется и проверка молча деградирует.
  Для этой статистики использовать интерактивно вкладку **«Генератор мира»**
  (`addons/game_design_tool`) в редакторе — она даёт то же самое плюс разбивку
  по сиду, без ловушки `--script`.

## Как писать проверку

Новый инвариант заслуживает проверки, когда его нарушение **тихое** — не падает
с ошибкой, а молча меняет поведение (характеристика не перенеслась, граф
распался, число ушло в ноль). Падающее с ошибкой ловится и без теста.

Куда класть: расширять существующий сценарий, если инвариант из той же области
(модель тела → `dev/body_traits_check.gd`, генератор → `dev/gen_verifier.gd`),
и заводить новый `dev/<область>_check.tscn` + `.gd` только под новую область.

Скелет ([scripts/check_template.gd](scripts/check_template.gd) — готовый файл
для копирования). Вся обвязка — счёт ассертов, итоговая строка, код выхода, мир,
системы, физкадры и сторож ошибок скрипта — живёт в `dev/check_harness.gd`, и
проверка её наследует:

```gdscript
extends "res://dev/check_harness.gd"
## Что проверяет и почему. Запуск: godot --headless dev/<имя>.tscn


func _ready() -> void:
    var world := _new_world()
    _add_systems(world, "gameplay", [S_Что_Проверяем.new()])

    await _run(world)

    _finish()


func _run(world: World) -> void:
    # --- 1. Что именно проверяем ---------------------------------------
    _check("человекочитаемое утверждение", условие, "детали при провале")
```

Правила, вытекающие из устройства проекта:

- **Сцена-обёртка обязательна.** Запускать надо `.tscn` с этим скриптом на
  корне, а не `--script <файл>`: под `--script` автолоадов не существует,
  `Entity` не компилируется, и проверки молча деградируют (ровно этим сломан
  `dev/preset_stats.gd`).
- **Мир и системы поднимаются руками** — `_new_world()` и
  `_add_systems(world, "группа", [...])`, наблюдатели через
  `world.add_observer(...)`. Берётся только то, что нужно проверке.
  `_add_systems` сортирует группу по `deps()` так же, как игра: без сортировки
  проверка гоняет системы в порядке списка, которого в игре нет (так годами
  было не видно, что ход и прыжок применялись на тик позже — см.
  `dev/system_order_check.gd`).
- **Физика — из настоящего физкадра**: `await _physics(n)` прогоняет n тиков
  группы "physics"; если тику нужно больше (толкать тело, крутить "gameplay") —
  переопределить `_on_physics_tick(delta)`.
- **Обрыв блока ошибкой скрипта — провал сам по себе.** SCRIPT ERROR прерывает
  только текущий блок, и раньше прогон доходил до итога с нулём провалов;
  обвязка считает такие ошибки движковым Logger'ом и валит прогон. Считать
  ассерты вручную (`EXPECTED_ASSERTS`) больше не нужно.
- **Такт мира — `ECS.process(delta, "группа")`** с тем же именем группы, что у
  системы. Дельту задавать явно (`0.016`, `1.0`) — прогон не привязан к
  реальному времени, и «одна секунда» это буквально `ECS.process(1.0, ...)`.
- **После `call_deferred` нужен `await get_tree().process_frame`.** Развоплощение
  и переход через дверь отложены (правило v9), и без ожидания кадра проверка
  увидит состояние ДО них.
- **Ассерт формулируется как утверждение о поведении**, а не о реализации:
  «остаток тела перетёк душе», не «поле current равно 119». Детали в третьем
  аргументе — они печатаются только при провале и должны объяснять, что пошло
  не так.
- **Числа брать из `GameConfig`/сцены**, а не хардкодить, если они настраиваемые
  — иначе тюнинг баланса ломает тест, который ничего не поймал.
- **Выход — через `_finish()`**: итоговая строка и код выхода, на которые
  опираются `dev/run_checks.ps1` и CI. Раннер верит итоговой строке больше, чем
  коду: проверка, сменившая сцену до своего quit(), выходит с чужим кодом.
- **Длинные проверки** с тысячами однотипных ассертов ставят
  `_print_passes = false` — в логе CI нужны провалы.
- **Сейвы в `user://` — настоящие сейвы разработчика**, отдельного каталога у
  проверок нет. Писать туда может не только явный вызов, но и механика по
  дороге: «Чистый выход» из тела начисляет очко и сразу сохраняет
  `skills.tres`, контрольная точка пишет `world_save.tres`. Проверка, которая
  дёргает такую механику, снимает байты файла в начале `_ready()` и в конце
  возвращает их ассертом — как `run_stats_check` и `abilities_check`.
  Утечку видно только по значениям в файле: прогнать проверку, сравнив
  `skills.tres`, `architect.tres`, `world_save.tres` до и после.

Дописав проверку по этому шаблону, регистрировать её в `dev/run_checks.ps1`
не нужно — файл, названный `dev/<область>_check.tscn`, скрипт находит сам
(`-ListChecks` покажет, что подхватилось). Обновить стоит только
§«Что именно проверяется» здесь — что именно проверка покрывает и после каких
правок её запускать, скрипт этого сам не знает.

## Чек-лист ручного плейтеста

Headless-проверки покрывают структуру, не ощущения. После них — реальный
прогон: играть `world.tscn` напрямую (пропустить меню) или через
**New Game** из `L_menu_map.tscn`, если менялся флоу старта. Смотреть
конкретно на область правки:

**Состояние для прогона набирается оверлеем, а не забегом.** В отладочной сборке
поверх мира висит панель читов (`dev/debug_overlay.tscn`, F1 — свернуть):
экран навыков не отходя от места, телепорт по слоям, выключенный распад, а в
меню отладки (`` ` ``) — очки навыков пачкой и эссенция Архитектора (уровень
карты комплекса покупается ею). Раньше, чтобы посмотреть на правку в дереве навыков, нужно
было бегать пару минут ради очков — то есть цена взгляда была выше цены правки.
Подробности и границы: [[Отладочный оверлей]].

- **Обводка силуэтом (`S_OutlineMask`)** — headless не рисует ни одного
  пикселя, поэтому картинка проверяется только здесь. Смотреть: контур ровной
  толщины и на мелкой детали, и на крупной стене; у составного объекта (дверь =
  рама + полотно + ручка) ОДИН силуэт без внутренних рёбер; ничего не мерцает,
  хотя `material_override` на объектах больше не стоит; объект вплотную и в паре
  метров, объект у края экрана — контур не обрезан; наведение и снятие не
  оставляют контур висеть. Заодно снять кадровое время с выделенным объектом и
  без него — цена постэффекта в карточке значилась риском.
- **Захват тела**: тело село вровень с полом, а не утоплено/парит (регрессия
  `foot_offset`/типа коллайдера — `body_traits_check` ловит только что офсет
  не нулевой, не то что он *верный* визуально); обводка красится по всей сложной
  геометрии, а не только по части меша; **пробовать
  захват в заспавненной комнате, а не только в хабе** — там своя геометрия
  пола, и именно там нашёлся баг «Тело здесь не поместится» на штатно стоящем
  теле (`body_form_check` теперь это проверяет headless, но по-другому
  расставленное новое тело/комната всё ещё стоит пощупать глазами).
- **Двери/переход**: игрок появляется перед дверью, ведущей назад, лицом в
  комнату (не боком); прицел не сбит рывком (пишется только позиция, не
  поворот — см. `CLAUDE.md`, «Doors ↔ graph edges»); дверной порог проходится
  шагом, не прыжком (`S_Movement.STEP_HEIGHT` — см. ниже).
- **Коридоры и раскладка сетью** (п. 7 «Сетки уровня»): headless сверяет маски
  и лучи, а не то, как этаж читается. Пройти пару этажей целиком (телепорт по
  слоям — оверлей): коридор подходит к комнате с одной-двух сторон, а не
  обходит её; две двери одной комнаты в разные коридоры — это обход, по нему
  реально вернуться другим путём; петля по коридору не выглядит вторым рядом
  вдоль того же коридора. **Тупик** (`SM_corridor_P_end`, 1–2 клетки от
  коридора): стоит проёмом к коридору, без щелей и провала на стыке, свет в нём
  есть, и читается он нишей, а не недостроенным проходом к двери. Стык двух
  коридоров на развилке не отличается от обычного коридора (шлюзов пока нет).
  Карта (мини и экран, `M`) рисует тупик рукавом и кольцо коридоров без разрывов.
- **Коридор — отдельный узел**: текущий узел меняется на каждой развилке при
  входе в боковой коридор, а не посреди прямого; мини-карта (посещённое и соседи)
  при коридоре на отрезок показывает сеть кусками — проверить, что это читается,
  а не выглядит обрывками; «Комнат посещено» в итогах забега не растёт от
  коридоров.
- **Сейв прежнего генератора**: забег, сохранённый на версии генератора ниже 2 (или
  с правленой `generator_version` в сейве), грузится у входа, с тем же запасом и
  телом; карта пуста, посещённых нет — а не в чужой комнате.
- **Перешагивание порогов (`S_Movement`)**: мелкие ступеньки и кромки
  комнатной геометрии (< 0.16 м — дверные пороги, стыки меша) проходятся
  ходьбой без рывка/подлёта, который выдавал бы телепорт вверх; настоящая
  стена или высокий бортик по-прежнему не пускают. Смотреть также на теле
  меньшего роста («ползун») — у него капсула тоньше, и естественный «перекат»
  голого `move_and_slide()` слабее, так что правка там нагляднее. **Уперевшись
  в настоящую стену, прыжок обязан срабатывать сразу** (был баг: `_try_step()`
  портил `is_on_floor()` на кадр, и прыжок у стены иногда молча не выходил).
- **Промпт интеракции**: показывает верную клавишу для действия
  (`SettingsManager.action_display_name()`), перерисовывается сразу после
  ребайнда в настройках без выхода из игры.
- **HUD «Отголосок»** (`hud_check` сверяет числа, а это — ощущение; спека —
  [[HUD — спека]]): дуга у прицела сиреневая вне тела и янтарная во плоти,
  переключается на вселении и выходе за четверть секунды с волной от краёв;
  на спокойном запасе гаснет, на заметном скачке и ниже 35 % видна, ниже 15 %
  бьётся. Ранний распад не давит постоянно — только слабая база, нарастание у
  порога. Излишек после выхода из тела — внешняя дуга с искрами. Во плоти при
  малом HP кровь пятнами по краю (не ровным кругом), удар — вспышка (сильнее от тяжёлого удара) и лёгкая тряска, пятна после
  удара ложатся иначе и перетекают, а не прыгают.
  Подсказка и сообщение читаются на светлой стене без плашки; «эхо» текста
  дрожит, а не двоится грязью; английский (сменить язык системы) не вылезает за
  края. Управление появляется на вселении и гаснет через ~4 с. Удобная сцена —
  оверлей: бессмертие выкл., тело с врагом рядом.
- **Мини-карта по Tab** (нужен ранг «Карты комплекса»: эссенция из меню отладки,
  покупка у Архитектора): открывается на ходу без паузы, Распад под ней тикает, мышь
  крутит камеру; контуры прорисовываются от текущей комнаты наружу, подписи —
  следом; точка игрока идёт за ним по коридору; Tab растворяет, M сворачивает
  её и открывает полную карту (только на последнем ранге, ниже — строка про
  терминал). Коридоры — бусы на нитке: нить только через открытые проёмы, так
  что соседние несоединённые ветки не сливаются в сетку; узнал новую комнату —
  карта плавно перевписывается, без скачка. Рамка рвётся, как дуга распада, но
  тише, и не спорит с точкой игрока; на светлой стене всё читается за счёт
  тени-пятна.
- **Враг (`S_EnemyAI`)**: на расстоянии стоит, при подходе разворачивается и
  бежит без рывков и залипаний в геометрии; в упор реально снимает HP (у края
  экрана проступает кровь, удар трясёт кадр), а не бьёт сквозь стену; добитое тело выбрасывает
  игрока в призрака без зависаний. Headless проверяет числа и переходы
  состояний, не то, ощущается ли погоня как угроза, а не декорация.
- **Вывод редактора (Output) во время игры** — открыт при запуске из
  редактора: `push_error` **«structural change during zero-copy system
  iteration»** значит, что где-то в `process()` структура мира менялась
  напрямую в обход `cmd`-буфера (см. «Правило v9» в `CLAUDE.md`) — тихий баг,
  который headless-прогон не покажет, если сценарий теста не задел ровно тот
  код-путь.
- **Оверлей «Сетка» «Генератора мира»** — headless считает отрезки, а не то, как
  схема читается. **Новый оверлей появляется только после перезапуска
  редактора** (или выключения-включения плагина). С выключенной «Геометрией»:
  решётка тёмная и не спорит с контурами, стрелка поворота не мерцает на
  перекрестье, стрелки дверей видны рядом с плитами «Коридоров» и указывают
  внутрь комнаты, крест тупика и черта стыка находятся глазом; выделение комнаты
  перекрашивает её контур и стрелку. С включённой — линии под потолками не лезут
  поверх мешей.
- **Экран карты комплекса** (`M` или терминал `screen2` в хабе) — headless
  проверяет данные и обратную проекцию, не картинку. Уровень карты ставится
  рангами «Карты комплекса» у Архитектора; эссенцию на них — из меню отладки
  («Прокачка»), экран Архитектора — телепортом в его комнату там же. Смотреть: этажи рядом не
  слипаются, клетка не раздута на крошечном этаже (`MAX_STEP`), ромб портала
  со стрелкой читается в углу комнаты и не лезет на значок содержимого, буква
  заглушки по центру круга, строка под картой меняется при наведении, клик по
  порталу переносит на слой и рамка видна; терминал подсвечивается обводкой
  целиком и подсказка «Карта комплекса» появляется у экрана, а не в стороне;
  без улучшения — строка «Нет связи с Архитектором», курсор после закрытия
  снова захвачен.
- **Экран смерти / итогов забега** — тема `assets/ui/default_theme.tres`
  применилась (не белые Control без стилизации — признак дублирующего
  `theme = ExtResource(...)` в сцене): заголовок прописными с эхом, строки
  «показатель ···· значение», по фону точки поднимаются вверх; на «Возродиться»
  штрих под кнопкой бежит, пока строится забег.
- **Меню «Отголосок»** (главное, пауза, настройки) — наведение мышью даёт эхо и
  бледный штрих, стрелка с клавиатуры ставит фокус (точка души слева, штрих в
  полную силу) и отличается от наведения; экран входит проявлением, а не
  скачком, и гаснет при закрытии; под паузой мир размыт и обесцвечен, а не
  закрыт панелью; «Сохранить» отвечает на себе «Сохранено» со значком;
  «Загрузить» без сейва недоступна и подписана «нет сохранений». Английский
  (`TranslationServer.set_locale("en")`) не ломает ни одной строки.
- **Меню настроек** — открыть **обоими входами**: из главного меню и из паузы.
  Вкладки выглядят вкладками (стили `TabContainer` берутся из темы, headless их
  не рисует), переключение не дёргает размер панели, «Отмена» возвращает туда,
  откуда пришли, а курсор после закрытия ведёт себя по правилам `UIManager`.
  Ребайнд щупать именно здесь: ловля нажатия идёт в `_input`, и `Esc` обязан
  отменять захват клавиши, а не закрывать экран.

## Границы

- Не переписывать/расширять `dev/gen_verifier.gd` или `dev/body_traits_check.gd`
  внутри этого скилла — это тестовые сценарии проекта, их правка (новые
  ассерты, новые инварианты) отдельная задача с собственным ревью, не рутинный
  прогон.
- Не считать headless `OK` подтверждением фичи целиком — это подтверждение
  инварианта, который проверяет конкретный сценарий, не больше.

Files in this skill

  • SKILL.md88 KB
  • eval_set.json3.9 KB
  • scripts/check_template.gd2.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…