Решили: «сколько разных эффектов с тегом висит на цели» считается по состоянию начала тика, а не
по живому списку: учитываются эффекты, легшие РАНЬШЕ этого тика, плюс снятые В ЭТОМ тике. Владелец
правила один — 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 (зеркальный детерминизм целиком).