Решили: тайминг удара объявляется в данных (AnimationArchetypeData.WindupShare и
ContactShares), покадровый путь удалён целиком, а инвариант «симуляция не читает клип» закрыт
текстовым гейтом SimulationReadsNoClipTests. Округление доли — вниз.
Почему: аудит 06.08 показал, что сим считал замах как hitFrame × duration / frameCount, то есть
разметка анимации задавала боевой тайминг — вплоть до решения «идти или бить»
(CombatPositioning.CanLandWindup). Правка маркера на два кадра двигала баланс всему ростеру, и
узнать об этом было неоткуда. Отвергнуто: оставить как есть (клип теперь ОДИН на 47 юнитов, цена
ошибки выросла на два порядка) и кэшировать чтение клипа (владельцем числа всё равно остаётся
ассет из другого окна).
Грабли:
- Смена первого параметра с
intнаfloatСКРЫВАЕТ старые вызовы.FollowThroughTicks(hitFrame, frameCount, interval, windup)после смены сигнатуры на(share, interval, windup, maxAnim)— всё ещё компилируется:intнеявно поднимается доfloat, аргументы съезжают на позицию влево, и функция считает совсем другое. Компилятор молчит, тест падает В ДРУГОМ МЕСТЕ с невнятным «предусловие: у юнита есть хвост». Урок: удалять старую перегрузку надо ТЕМ ЖЕ шагом, что менять сигнатуру — иначе она не мёртвый код, а ловушка. - Округление — условие переезда, а не вкусовщина. Покадровый путь делил целочисленно (floor).
Оставь у доли округление к ближайшему — и момент удара сдвинется на тик у 40% длительностей
свинга. Замер:
floor(share × duration)совпадает с(hit × duration) / frameCountна всех значениях,round— расходится на 24 из 60. Проверено перебором до правки, а не после. - Цена всё равно есть, и её выбрал Макс. Любая унификация двигает кого-то: floor переносит 37 замеренных юнитов бит в бит, но сдвигает 10, кому долю задавали вручную. Решение — «время замаха должно быть у всех одинаковым», то есть персональные доли сняты сознательно.
- Побочная выгода:
ContactSubTickстал честным. Пока путь доли округлял к ближайшему, истинный момент мог оказаться раньше тика модели, и дробь [0..1) его не выражала — метод отдавал ноль. С округлением вниз остаток есть всегда, и подача получает настоящую долю тика. - Тестовая фабрика обязана делать то же, что редакторный замер.
TestVisual.Makeстроила клип с маркерами, но не объявляла долю — то есть собирала юнита, невозможного в проде: с клипом, но с телеграф-полом в три тика вместо настоящего замаха. 14 тестов падали именно на этом. - Гейт инварианта — текстовый, и это осознанно. Возврат зависимости выглядит как строка
visual.AttackHitFrame: она компилируется, работает и молчит. Проверять надо исходник, как это уже делаетFeelToggleCoverageTestsдля тумблеров.
Владелец правды: AttackTiming.WindupTicksFromShare (формула и округление),
AnimationArchetypeData.WindupShare/ContactShares (числа), SimulationReadsNoClipTests
(инвариант), ГД-журнал 2026-08-06/6 (решение Макса).