Решили: закон видимости эффектов распространён на ЧИСЛО СТАКОВ. Всё, что стаками влияет на исход
(урон периодики, уязвимость «Углей», топливо детонации, шипы от стаков), считает по снимку начала тика
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.