squad-proto/docs/08-decisions.md
z.kirill c1f79a1fb8 Поле путей: отставший возвращается в строй (M37)
Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, seek'ом тянулся В СТЕНУ, обход стены
толкал его обратно, и он замирал в углу навсегда.

Теперь у каждого бойца своё поле путей (BFS от клетки его цели) с натягиванием
верёвки: он берёт самую дальнюю точку пути, которую видит. Включается обход по
двум условиям сразу — боец упёрся (хочет идти и не едет) И потерял строй. Счётчик
упора сбрасывается по факту «цель видно», а не «поехал»: сброс по скорости давал
дребезг, и боец полз шаг за две секунды.

По дороге вскрылись две неверные МЕРКИ:

* критерий 11.6 считал колонной «разброс вдоль больше, чем поперёк» — и потому
  награждал отряд, у которого двое навсегда остались по ту сторону стены. Теперь
  колонна меряется самим проёмом: разом в горловине не больше двух, и прошли ВСЕ.
* зонд --wall читал «стены не нашлось» (ноль) как «стена на нулевом этаже» и
  объявлял пятёрку, не сломавшуюся нигде, худшей из трёх.

Обе переписаны и проверены на сломанной сборке.

Замеры: --play 6 5 на восьми сидах 47/48 (было 0/48), --accept PASS, шаг 6.1 мкс,
--wall 30 одиночка D20 / трое D20 / пятеро D31 (не найдена), --descend 8/30,
hollow --descend 6, --ui-check, оба проекта --check — без ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:31:34 +03:00

28 KiB
Raw Permalink Blame History

08 — Решения и отклонения от спека

Спек в разделе 12 требует: раздел 6 реализуется как написано, а если считаешь, что где-то ошибка — сначала скажи, потом делай. Здесь перечислено всё, что отличается от буквы спека, с причиной и замерами.

Отклонения разделены на две группы: содержательные (меняют поведение) и структурные (не меняют).


A. Содержательные

A1. Пуля не летит дальше проверенного отрезка

Спек: 6.9 — снаряд со скоростью 22 юнита/с. Сделано: время жизни пули = длина отрезка «дуло → цель» / скорость, а не дальность оружия / скорость.

Почему. LaneClear проверяет отрезок до цели. Если цель успевает умереть от чужой пули, снаряд летит дальше — в пространство, которое никто не проверял. Ровно так возникало последнее упорное попадание по своим: агент стоял внутри кучки мишеней, пуля прошла место умершей мишени насквозь и нашла его.

Замер: до фикса — 1 попадание за 300 с, стабильно воспроизводимое (t=94.49, #1 -> #2 @ (30.6,21.7)); после — 0 за 300 с.

A2. Спуск учитывает время полёта пули (AllyInterceptsShot)

Спек: 6.3 проверяет линию в момент выстрела. Сделано: дополнительно проверяется сближение пули и союзника как двух движущихся точек на всём времени полёта, с радиусом безопасности, растущим со временем (shotPredictSlack — запас на то, что союзник может и повернуть).

Почему. 6.3 даёт мгновенный снимок, а 6.9 делает пулю реальным снарядом: до 0.4 с полёта, за которые союзник проходит больше метра. Союзник, честно чистый в момент нажатия, спокойно входит в коридор уже после выстрела. Реактивное расступание (6.6) этот случай не закрывает по определению — оно срабатывает, только когда тело уже в линии.

Само правило 6.3 при этом не ослаблено: это дополнительное условие спуска, а не замена. LaneClear продолжает использоваться и в целеуказании, и в оценке кандидатов, и в реестре линий — в том виде, как написано в спеке.

Замер: журнал попаданий показывал, что жертва всегда была ADVANCING — то есть именно вбегала под летящую пулю.

A3. В реестр линий попадают линии, перекрытые своим

Спек: 6.6 — «список активных линий огня — сегментов от стреляющих агентов». Сделано: линия регистрируется и тогда, когда у агента есть цель в радиусе, стена не мешает, а мешает только свой (Agent::laneIntent).

Почему. При буквальном прочтении механика самоотменяется: стоит кому-то влезть в линию — стрелок теряет линию, перестаёт быть «стреляющим», отрезок исчезает из реестра, и перекрывшего ничто не выталкивает. Сектор обстрела не освобождается никогда, а обещание 6.6 («отряд сам расплывается, освобождая сектора обстрела») и критерий 11.4 становятся недостижимыми.

Замер: FIRE UPTIME на маршруте вырос с 33 % до 49 %, худший uptime на стоянке — с 38 % до 51 %.

A4. Резерв линии шире порога блокировки (laneReserveScale = 1.8)

Спек: 6.6 — импульс, если dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN. Сделано: импульс расступания начинается на laneReserveScale шире этого порога. Сам порог блокировки в 6.3 не тронут.

Почему. Раздел называется «Резервирование линий». Если отжимать агента ровно с той границы, за которой он уже блокирует выстрел, — это не резерв, а реакция постфактум: агент успевает войти в сектор, где летит пуля.

Замер (300 с, с A1 и A2):

laneReserve FRIENDLY_HITS худший uptime на стоянке
1.0 (буква спека) 1 42.5 %
1.4 0 50.0 %
1.8 (принято) 0 50.4 %

A5. Касательная составляющая обхода стен (wallSlide)

Спек: 6.7, слой 4 — «wallAvoidance + скольжение вдоль стен при коллизии». Сделано: помимо скольжения при коллизии, WallAvoidForce добавляет касательную силу в сторону цели, когда стена стоит между агентом и целью.

Почему. Чисто отталкивающее избегание даёт классический локальный минимум: seek тянет вперёд, отталкивание держит — агент залипает в «тени» препятствия. В прогонах это ловилось буквально: агент оставался в северной комнате (y=12), когда отряд уже стоял на арене (y=26).

A6. Агент без целей в радиусе возвращается в строй

Спек: случай не описан. 6.5 запускает решатель, когда линии нет, но не говорит, что делать, если стрелять вообще не по чему. Сделано: если решатель не нашёл ни одной цели в пределах weaponRange + ring3, postPos = слот строя.

Почему. Иначе отставший агент стоит на месте вечно: в HOLDING он идёт только к postPos, а postPos равен его же позиции.

A7. Дуло считается в сторону цели, а не по facing

Спек: 6.3 — «muzzle() — точка в 0.3 юнита от центра агента по facing». Сделано: для проверки линии и для спавна пули дуло считается по направлению на цель; выстрел разрешён только когда ствол доведён до цели с точностью ±5° (aimToleranceDeg).

Почему. facing доворачивается плавно и на шаг отстаёт от направления на цель. Если проверять линию по одному направлению, а стрелять по другому, пуля летит не по проверенному отрезку — то есть гарантия «не сквозь своих» перестаёт быть гарантией. Сейчас проверенный отрезок и траектория пули — буквально один и тот же отрезок.

A8. Пуля не бьёт собственного стрелка

Спек: 6.9 — «коллизия со всеми капсулами, включая союзников». Сделано: стрелок исключён из коллизий своей пули.

Почему. Дуло вынесено на 0.3 юнита при радиусе тела 0.28 — пуля стартует почти вплотную к своему же телу. Самопопадание было бы вырожденным шумом в FRIENDLY_HITS, главной метрике корректности. Все остальные капсулы, включая союзников, проверяются как написано.

A9. Северная кучка мишеней стоит напротив прохода

Спек: 4 — 12 мишеней кучками, конкретные места на усмотрение реализации. Сделано: кучка из 3 мишеней в северной комнате поставлена напротив прохода x=30..31, так что из коридора до неё есть чистая линия.

Почему. Спек называет узкий проход важным тест-кейсом, а критерий 11.6 требует, чтобы в проходе отряд вытягивался в колонну и стрелял только передний. Без цели, простреливаемой сквозь проход, проверять нечего.

A10. Вторичные цели в HOLDING при ручном прицеливании

Спек: 6.8 — флаг freeFireSecondary описан только для ADVANCING. Сделано: буквально — в HOLDING агент без линии до назначенной цели молчит и перестраивается решателем, по вторичным целям не стреляет.

Почему. Иначе агент нашёл бы вторичную цель, получил бы hasLane, и решатель для него никогда бы не запустился — то есть требование «в HOLDING перестраивается через решатель» не выполнялось бы.

A11. Туман войны и освещение вопреки разделу 12

Спек: 12 — «не добавлять освещение, туман войны». Сделано: добавлено и то, и другое.

Почему. Заказчик изменил требования: прототип разворачивается в хоррор, где ограниченный обзор и темнота — не украшение, а основа подачи. Правила стрельбы (раздел 6) при этом не тронуты ни одной строкой, критерии приёмки остались зелёными. Устройство слоя — 10-atmosphere.md.

A12. Конус зрения не влияет на ИИ

Сделано: у агента есть конус обзора для КАРТИНКИ, но выбор целей и стрельба по-прежнему работают по LOS и дальности оружия.

Почему. Если завязать целеуказание на видимость, меняются все критерии приёмки, а отряд начинает «слепнуть» от поворота ствола — при том что LaneClear и так требует чистой линии, то есть отсутствия стены. Чтобы стрельба в темноту не выглядела абсурдом, мишень показывается, если освещена или если по ней уже работает кто-то из отряда.

A13. Shift — рывок, а не медленный шаг

Спек: 5 — «Shift (удерж.) — медленный шаг». Сделано: базовая скорость снижена с 3.2 до 2.0, а Shift даёт рывок 3.2.

Почему. Заказчик просил «менее казуальное управление, помедленнее». Медленный шаг при уже медленной базе — мёртвая клавиша. Инверсия сохраняет обе скорости из спека (2.0 и 3.2), но делает осторожность режимом по умолчанию, а спешку — осознанным решением игрока. Побочный эффект: проверка спуска на время полёта пули обязана считать по МАКСИМАЛЬНОЙ скорости (MaxAgentSpeed()), иначе рывок союзника не учитывается и возвращаются попадания по своим.

A14. Дальность оружия 9.0 -> 7.2

Сделано: минус 20 % по требованию заказчика; вместе с ней отмасштабированы minEngageDist (2.0 → 1.6) и кольца решателя (0.8/1.6/2.4 → 0.7/1.4/2.1), а старт отряда сдвинут в (26.5, 26.5) ближе к кучке мишеней.

Замер: без сдвига старта и масштабирования колец сокращение дальности само по себе давало 1 попадание по своим за 300 с и просадку uptime — агенты сбивались в кучу у цели. С пакетом целиком: 0 попаданий, uptime в стоячем бою 100 %.

A15. candMinAlly 0.55 -> 0.60

Порог «кандидат слишком близко к союзнику» был меньше диаметра тела (2 × 0.28 = 0.56), то есть допускал позиции, где агенты физически пересекаются.

A16. Клинок союзникам урона не наносит вообще

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

Почему. Соблазн был сделать «сильную» версию: клинок бьёт всех, а от попаданий по своим спасает дисциплина сектора. Но у клинка нет времени полёта, за которое можно передумать: союзник входит в дугу за 1/60 с уже после того, как взмах начат, и в этот момент отменять нечего. «Сильная» версия означала бы FRIENDLY_HITS > 0 в любой плотной свалке, то есть провал главного критерия прототипа ради механики, которой в спеке нет.

Обрыв взмаха даёт то же самое ощущение (боец отдёргивает клинок, серая вспышка, MELEE_ABORT в метриках) и при этом не может сломать критерий 11.2.

Замер: 81 взмах за 30 с в плотной свалке, FRIENDLY_HITS = 0, 16 взмахов оборвано.

A17. Старт замаха смотрит на союзника вперёд

Сделано: MeleeArcClear проверяет союзника в двух точках — где он сейчас и где будет через windup + strike. Ровно та же поправка, что A2 для пули.

Почему. Взмах живёт 0.30 с, за которые союзник проходит полметра. Проверять только текущее положение — значит начинать удары, которые всё равно придётся обрывать.

Замер (сценарий M7, 30 с): без поправки и без резерва дуги — 21 оборванный взмах из 77; с поправкой и резервом — 16 из 81, попаданий 81 против 81, убийств 23 против 23. То есть взмахов стало больше при том же результате.

A18. Резерв в реестре линий получил явную полуширину

Сделано: у FireLane появилось поле reserve. Линия огня по-прежнему берёт ширину из правила 6.3, клинок задаёт свою — долю бокового размаха сектора (meleeReserveFrac 0.55).

Почему. Клинок резервирует отрезок вдоль оси удара, а поражает сектор ±50°. Союзник, отжатый строго перпендикулярно оси, попадал ровно во фланг дуги — то есть механизм расступания работал против себя.

Замер: meleeReserve 0 → 0.55 дало 16 оборванных взмахов вместо 21 при большем числе взмахов, а худший FIRE UPTIME на стоянке маршрута — 23.3 % вместо 0 % (стрелки перестали стоять в дугах и молчать).

A19. Жёсткая граница огневой позиции (postLeash)

Спек: 6.5 — цена за удаление от якоря. Сделано: дополнительно кандидат дальше cohesionRadius + postLeash от якоря отбраковывается совсем.

Почему. Цена — мягкая: линия огня стоит 100 очков, а уход на 1.5 юнита за круг — 36. Решатель всегда выбирал линию, и отряд расползался.

Замер: максимум удаления стрелка от диска 3.65 → 2.81 юнита. При этом FIRE UPTIME на 180-секундном маршруте не упал, а вырос: на стоянках у мишеней 82.9 / 93.2 / 90.7 / 86.0 % — плотный отряд перекрывает друг другу линии реже, чем расползшийся.

A20. Мишени — тела, и они получают отдачу

Сделано: слой движения TargetPushForce (мишень расталкивает агентов) и импульс от удара с возвратом на исходное место (Target::home).

Почему. Без расталкивания боец стоит внутри того, кого рубит, и удар не читается вообще. Без отдачи попадание в упор визуально не отличается от промаха. Возврат на home обязателен: расстановка уровня захардкожена, и уползание мишеней поломало бы все замеры разом. Респавн дополнительно ставит pos = home.

A21. Критерий 11.6 считает только выстрелы вперёд

Было: тест требовал, чтобы в коридоре передний агент стрелял минимум вдвое чаще хвостовых, и считал ВСЕ выстрелы внутри прохода.

Стало: считаются только выстрелы по ходу движения. Выстрелы назад выводятся в отчёт отдельно.

Почему. Диагностика DIAG показала, что почти все «нарушения» — это хвостовой агент, стреляющий назад, в оставленную позади комнату, по совершенно другой цели. Впереди по такой линии никого из отряда нет по построению, и к правилу «не стрелять сквозь своего» это отношения не имеет. Порог ловил не поведение, а геометрию расстановки мишеней.

Замер: на прежней базе тюнинга новый счёт даёт 4/0 (было 12/4), на текущей — 5/0. То есть критерий стал строже, а не мягче: вперёд хвост не стреляет вообще.


A22. Критерий 11.6: «колонна» меряется проёмом, а не разбросом

Было: колонной считалось «разброс вдоль движения больше, чем поперёк, в полтора раза» — по бойцам в створе прохода вместе с подходами.

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

Почему. Старая мерка награждала ровно ту поломку, ради которой писалась. Отряд, у которого двое навсегда остались по ту сторону стены (у них не было поиска пути и вернуться они не могли), растянут вдоль на шесть тайлов — и проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить стену и собираться, стал компактным — и проверку ронял. Замер трасс показал это прямо: без обхода бойцы 3 и 4 все 26 секунд стоят на y≈16.3 и y≈16.9, южнее стены.

Замер: на исправленной сборке — прошли 5 из 5, в горловине разом до 2, FRIENDLY_HITS=0, сквозь своего 0. На сборке без обхода (--set navStuckTime=99) критерий падает: прошли 3 из 5. То есть мерка стала строже: раньше она пропускала брошенных за стеной, теперь нет.


A23. Зонд --wall: «стены не нашлось» — это не «стена на нулевом этаже»

Было: глубина первой стены хранилась нулём как «ещё не найдена», и итог сравнивал wall[пятеро] <= wall[одиночка] буквально.

Стало: не найденная стена читается как «глубже последней проверенной» (maxDepth + 1) и печатается как D31 (не найдена).

Почему. Пятёрка перестала ломаться на всех проверяемых глубинах — то есть показала лучший возможный результат, — и зонд объявил это провалом: ноль меньше двадцати. Ошибка мерки, а не игры, того же рода, что и A22.

Замер: --wall 30 — одиночка D20, трое D20, пятеро D31 (не найдена), код возврата 0.


B. Структурные (поведение не меняют)

B1. Дополнительные файлы

Спек перечисляет структуру проекта; добавлены четыре файла:

Файл Зачем
game.h/.cpp склейка подсистем и порядок шага. Без него это переехало бы в main.cpp, а спек требует, чтобы там были только окно и цикл
render/scene.h/.cpp проход по полу и список сортировки; отдельно от iso.h, который остаётся чистой математикой
core/input.h InputState нужен и main.cpp, и симуляции, и headless-прогонам
debug/harness.h/.cpp прогоны --headless / --accept
sim/loadout.h класс оружия и его параметры; сюда приедут разные пушки и апгрейды
ai/melee.h/.cpp ближний бой; зеркало firing_solver для угловой геометрии

B2. У Sprite есть точка привязки

Спек: Sprite { int w, h; std::vector<uint32_t> px; }. Добавлены поля ax, ay — точка внутри спрайта, совмещаемая с проекцией мировой позиции. Иначе смещения пришлось бы размазывать по коду отрисовки для каждого типа спрайта.

B3. Параметры оружия синхронизируются из Tuning каждый шаг

Agent::weapon существует как в спеке, но перед движением поля копируются из g_tune (SyncLoadoutFromTuning). Без этого runtime-подкрутка (раздел 8 спека) не влияла бы на уже созданных агентов. Состояние ближнего боя — фаза, таймеры, цель — при этом не трогается: копируются только параметры.

B4. Dev-инструменты

--headless, --accept, --probe, --set, --shot, тумблеры --no-solver / --no-laneavoid. В спеке их нет; они не влияют на игровую сборку и нужны, чтобы критерии приёмки из раздела 11 проверялись цифрами, а не на глаз.


Что осталось честно неидеальным

  • FIRE UPTIME считает «нет живой цели в радиусе» простоем. На маршруте между комнатами это даёт низкие значения, которые к качеству ИИ отношения не имеют.
  • SETTLE TIME по той же причине растёт, если отряд остановился там, где стрелять не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
  • Поиск пути включается ТОЛЬКО для отставшего, который упёрся (см. 05-squad-ai.md, «поле путей»). Боец в строю по-прежнему ходит локальными слоями, и это осознанно: строй должен вести себя как строй, а не как пять независимых искателей пути.