Решили: «сколько разных эффектов с тегом висит на цели» считается по состоянию начала тика, а не по живому списку: учитываются эффекты, легшие РАНЬШЕ этого тика, плюс снятые В ЭТОМ тике. Владелец правила один — EffectSystem.CountUniqueTaggedAtTickStart; локальная копия счёта в AbilitySystem удалена.

Почему: зеркальный бой расходился детерминированно — Mirror_SquadSeries_NeverDiverges(5), тик 543. У левого Defender висел effect.spore_mend, у правого нет; всё остальное совпадало бит-в-бит. Трассировка показала порядок событий внутри тика: левый Хранитель углей очищением снял яд со своего защитника, левый Друид успел детонировать отравленного врага и вылечить своих — а к ходу правого Друида яд с его цели уже сняли, и «Взрыв спор» ушёл в пустоту.

То есть исход решало место юнита в обходе, а не бойцы. Это нарушение того же закона видимости, который уже держат EffectTagMask (пересчитывается на границе тика) и RuntimeEffect.StacksAtTickStart. Дыра была ровно в одном месте: счёт уникальных читал ActiveEffects как есть, а оттуда снятое исчезает немедленно.

Отвергнутые варианты:

  • Отложить фактическое удаление эффекта до конца тика. Чинит симптом, но ломает всё, что законно читает живой список (контроль, щиты, повторное наложение), и превращает «снят» в третье состояние между живым и мёртвым. Диспел уже решил ту же задачу иначе — снимок начала тика, — и второй механизм рядом стал бы вторым владельцем правила.
  • Считать по EffectTagMask. Маска заморожена правильно, но отвечает только «есть или нет», а Друиду нужно ЧИСЛО разных ядов: маска не различает один яд от трёх.
  • Оставить как есть, а зеркало «поправить» составами. Тест ловил настоящую асимметрию: пока она жива, любой вывод стенда о равных составах ничего не значит.

Грабли:

  • Тест, который кладёт эффект и тут же его использует, выражает гонку, а не игру. Существующий SporeBurst_HealsAllies_PerUniquePoison_NotPerStack звал CommitPending, но не двигал счётчик тика — и после починки честно упал: яды числились «легли в этом тике». Лечится MockCombatContext.AdvanceTick, который для этого и заведён. Мок про это даже предупреждает докстрингом — я прочла его уже после того, как тест покраснел.
  • Симптом всплыл далеко от причины. Расхождение искали в реформе типов урона и в свежем «ударе с разбега» (тот и правда сдвинул тайминги так, что клинз и детонация впервые сошлись в одном тике), но дефект был давний и ждал именно такого совпадения. Проба точного сравнения (MirrorBitProbe) назвала тик рождения, а трассировка событий внутри тика — виновника; без неё гипотезы множились бы и дальше.
  • Порядок сторон в обходе — это не «деталь реализации», а игровое преимущество. Любое чтение изменяемого за тик состояния для принятия решения обязано брать снимок начала тика.

Владелец правды: EffectSystem.CountUniqueTaggedAtTickStart и RuntimeUnit.EffectsRemovedThisTick, тесты PoisonBurnThornsSliceTests.UniqueTagCount_CountsWhatWasThereAtTickStart (точечный инвариант) и MirrorMatchTests (зеркальный детерминизм целиком).