Решили: закон видимости эффектов распространён на ЧИСЛО СТАКОВ. Всё, что стаками влияет на исход (урон периодики, уязвимость «Углей», топливо детонации, шипы от стаков), считает по снимку начала тика RuntimeEffect.StacksAtTickStart; живой Stacks остаётся для записи (набор стака, цена очищения) и для пересчёта stateful-вклада (OnStacksChanged получает контекст с liveStacks: true).

Почему: второй путь дефекта детерминизма BAL-014. Зеркало расходилось на тике 300 (тик рождения, точное сравнение): зеркальные Огненные мечники детонировали «Угли» на 354,0 и 244,8 урона при полностью одинаковом составе эффектов. Замер слагаемых показал, что оба слагаемых удара — урон за стак (20 × N) и множитель уязвимости (1 + 0,01 × N) — читали одно и то же число, но разное у сторон: 18 против 13. Пять стаков съедала ЦЕНА очищения «Длани жизни», и порядок обхода по глобальному Id ставил союзного пастыря ДО вражеского мечника у левой стороны и ПОСЛЕ — у правой. Ровно та асимметрия «свой ходит раньше чужого», что была второй половиной BAL-014 в SeparationSystem.

Отвергнутая альтернатива — снимать стаки/эффекты отложенно, в конце тика (правка CommitPending): проверена замером 29–30.07 и урон не изменила вовсе (те же 354,0 против 244,8), потому что гонка была в ЧТЕНИИ, а не в записи. Вторая отвергнутая — убрать цену очищения в стаках: лечит один контент, а не причину; любая механика, правящая стаки посреди тика, вернула бы расхождение.

Грабли:

  • Формула «снимок, а если его нет — живое» появилась в четырёх местах сразу и была сведена в одного владельца — RuntimeEffect.VisibleStacks. Читатели зовут свойство, а не собирают условие сами.
  • Снимок нельзя отдавать пути Reapply: набранный стак обязан дорастить щит и стат-модификатор в тот же момент, иначе прибавка не случится никогда — второго вызова под это изменение не будет.
  • У эффекта, родившегося внутри тика, снимка нет, и 0 там значит «снимка ещё нет», а не «стаков нет»: единственным своим стаком он влияет на мир сразу.
  • Тест Ignition_BurnsEmberStacks_AndConsumesThem проверял очистку маски тегов и был ложно-зелёным: маску строит только CommitPending, которого в тесте не было, поэтому маска всегда читалась пустой. Проявилось лишь после этой правки. Мок получил AdvanceTick(params RuntimeUnit[]) — сдвиг тика с проявлением отложенного, чтобы граница тика в тестах имела один вход.

Проверка: MirrorMatchTests 25/25 и точный зонд HoldsExactlyForFullBattle 8/8 окон на полном бою (7200 тиков) — с ВКЛЮЧЁННЫМ очищением «Длани жизни», которое до этого было выключено именно из-за дефекта.

Владелец правды: RuntimeEffect.StacksAtTickStart / VisibleStacks, EffectContext.Stacks, EffectSystem.CommitPending; тесты MirrorMatchTests, зонд MirrorBitProbe.