Решили: у числа стаков ровно один вход на чтение и один на запись. RuntimeEffect.Stacks стал
get/private set, снаружи он правится только через AddStacks / RemoveStacks (второй сам клампит в
ноль, чтобы это правило не носили вызывающие), а снимок начала тика двигает единственный метод
CommitStackSnapshot, который зовётся один раз за тик из EffectSystem.CommitPending.
Почему: решение Макса, и оно верное. Правило «влиять на исход можно только числом начала тика»
существует, но держалось на памяти: три места писали поле напрямую (набор стака, цена очищения, сход
«Углей»), четвёртое присваивало снимок снаружи. Пока запись открыта, новая механика возвращает дефект
30.07 одной строкой eff.Stacks--, и никакой сторож про это не узнает.
Развилка — чем держать инвариант: тестом или типом. Выбран тип. Правило CLAUDE.md «инвариант, живущий между файлами, идёт в тест» решает случай, когда инвариант проверить можно, а запретить нельзя. Здесь запретить можно: без сеттера код, нарушающий правило, просто не компилируется. Компилятор строже теста и не требует, чтобы кто-то вспомнил его дописать. Тест-сторож на «никто не пишет поле» пришлось бы делать рефлексией по сборке — дороже и слабее.
Грабли: тест, ставивший Stacks = 8 ради проверки малого счёта, теперь идёт тем же путём, что бой
(RemoveStacks(Stacks - 8)). Это не косметика: тест, который правит состояние в обход игровых входов,
проверяет не игру, а собственную выдумку — ровно так и жил ложно-зелёный Ignition_BurnsEmberStacks.
Владелец правды: RuntimeEffect (Stacks, StacksAtTickStart, VisibleStacks, три метода),
EffectSystem.CommitPending; сторожа — MirrorMatchTests и зонд MirrorBitProbe.