# 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 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), «поле путей»). Боец в строю по-прежнему ходит локальными слоями, и это осознанно: строй должен вести себя как строй, а не как пять независимых искателей пути.