Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил. Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец, оказавшийся за стеной от своего слота, 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>
369 lines
28 KiB
Markdown
369 lines
28 KiB
Markdown
# 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](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](05-squad-ai.md), «поле путей»). Боец в строю по-прежнему
|
||
ходит локальными слоями, и это осознанно: строй должен вести себя как строй,
|
||
а не как пять независимых искателей пути.
|