Решили: всё изменяемое состояние RuntimeEffect закрыто глаголами, публичных полей-состояний не осталось. Стаки — AddStacks / RemoveStacks / CommitStackSnapshot; удерживаемый щит — HoldShield / AddHeldShield / ReleaseHeldShield; длительность — SetDuration / TickDownDuration / EndDuration; служебный таймер — IsTimerDue / ScheduleTimer / RescheduleTimer; заряды реактива — ArmCharges / TryConsumeCharge (+ ChargeCount и ChargeReadyTick(i) на чтение).

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

Развилка — чем держать инвариант. Правило вики «инвариант между файлами идёт в тест» покрывает случай, когда правило можно проверить, но нельзя запретить. Здесь запретить можно: нет сеттера — нет компиляции. Компилятор строже теста и не требует, чтобы кто-то не забыл его дописать.

Что нашлось попутно, и это главное:

  • Второй владелец величины щита. ShieldComponent.OnExpire пересчитывал потенция × стаки вместо того, чтобы снять ровно удержанное. Числа совпадали — до первого рестака внутри тика: ctx.Stacks теперь отдаёт снимок начала тика, и пересчёт снимал бы число, которого эффект никогда не держал.
  • Мёртвое поле ReactiveReadyTick — ни читателя, ни писателя во всём проекте. Прежняя модель реактива (один внутренний кулдаун) была заменена таймером на каждый заряд. Сверено с ГДД перед удалением: щит «Оплота» специфицирован как два заряда с независимой перезарядкой, значит из замысла не срезано ничего — только поле, которое ничего не реализовывало.
  • Дублирование правила зарядов. «Найти первый готовый и отправить его на перезарядку» жило дважды, в «Оплоте» и «Изворотливости». Порядок обхода тут не косметика: он делает расход зарядов детерминированным и одинаковым у зеркальных сторон, а такое правило обязано лежать в одном месте.

Грабли: тест, который правит состояние в обход игровых входов, проверяет не игру, а свою выдумку — ровно так жил ложно-зелёный Ignition_BurnsEmberStacks. Поэтому тесты переведены на те же глаголы (SetDuration(60), RemoveStacks(...), ChargeReadyTick(i)), а не получили калитку в виде публичного сеттера «только для тестов».

Владелец правды: RuntimeEffect (все глаголы состояния), EffectSystem.CommitPending (граница тика); сторожа — MirrorMatchTests, DefenderSliceTests, зонд MirrorBitProbe.