Решили: всё изменяемое состояние 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.