Решили: три механики мага ветра собраны компонентами эффектов — RangedWardComponent, EvasionComponent, AllyGustComponent; счётчик принятых Ударов RuntimeUnit.HitsTaken считается в CombatSimulation.ResolveIncoming, а не в EffectSystem.RunPreDamage.

Почему: место счёта было развилкой. RunPreDamage выглядит естественнее — счётчик нужен только реакциям, — но он выходит сразу, если на цели нет ни одного эффекта. Тогда период «каждой четвёртой» поехал бы у бойца, с которого уклонение сняли и повесили заново: удары, принятые без эффекта, в счёт не попали бы. ResolveIncoming — единственное место, где тратятся все защиты цели, и он зовётся всегда; там же счёт идёт ДО опроса реакций, зеркально HitsMade у слепоты.

Грабли:

  • Заряд реакции не взводится сам. RuntimeEffect.TryConsumeCharge молча отвечает «нет готовых», если массив зарядов не создан, а создаёт его сам компонент в OnApply через ArmCharges — так делают DodgeComponent, BlockComponent, ParryComponent, PhaseShiftComponent. Новый компонент с перезарядкой, забывший эту строчку, не срабатывает никогда и без единой ошибки в логе: три теста «Порыва» падали на пустом месте, пока причина не нашлась. Ищется тяжело — симптом выглядит как «условие не выполняется».
  • UnitData абстрактен. ScriptableObject.CreateInstance<UnitData>() возвращает null, а null.GetType() в тестовом хелпере With даёт голый NullReferenceException без намёка на причину. В тестах брать конкретного наследника — EnemyData или RelicData.
  • Результат метода нельзя передать в in-параметр (CS8156): RunPreDamage(unit, in Hit(...)) не компилируется. Либо локальная переменная, либо просто убрать in — компилятор сам заведёт временную.
  • Дееспособность реакции не всегда про носителя. Маркер IRequiresAgencyComponent проверяет того, на ком висит эффект; у «Порыва» действует АВТОР, а носителя просто дёргают. Пришлось спрашивать author.CanActAtTickStart руками — маркер здесь дал бы ровно обратное поведение (оглушённый союзник переставал бы спасаться, а оглушённый маг продолжал бы работать).
  • Своего поля скорости у юнита нет. Для выбора стороны перпендикулярного рывка направление движения берётся как Position − PreviousPosition.

Владелец правды: Assets/_Project/Scripts/Combat/Effects/Components/ (три компонента), RuntimeUnit.HitsTaken, тест ZephyrSliceTests.