Решили: односторонние записи в RunState идут только через шину команд (IRunCommands →
RunCommandBus → RunCommandApplier → append-only RunCommandLog), а сами мутаторы
RunStateService стали internal. Соло идёт этим же путём.
Почему internal, а не соглашение: Guildmaster.Guild — отдельная сборка, а все вызывающие
(Game, UI) живут в других, поэтому граница сборки превращает правило в барьер, который видит
компилятор. Рассмотренные альтернативы: комментарий «ходите через шину» виден только тому, кто и так
собирался; explicit interface implementation дала бы тот же эффект внутри одной сборки, но здесь она
не нужна — граница уже есть; тест «никто не зовёт мутаторы напрямую» проверял бы текст кода, а не
типы, и обходился бы рефлексией. Тесты видят internal через InternalsVisibleTo: сами мутаторы
по-прежнему заслуживают прямых тестов (валидация слота, границы вместимости), и гонять их через шину
значило бы проверять два механизма одним тестом.
Границу шва провели честно, а не по максимуму. Переехали только односторонние записи: позиция и кит
в расстановке, золото, снятие реликвии, награда за победу. Четыре метода, отвечающие «вышло ли» СРАЗУ —
TrySpendGold, TryAddRelic, TrySpendRestart, IncreaseCapacity, — остались публичными и пишут в
забег мимо лога. Причина не в объёме работы: синхронный ответ несовместим с «хост подтвердит», и
завернуть их — значит переделать магазин, награды и лоадаут под оптимистичный показ с откатом, то есть
сделать отложенный шаг транзакций (Planning - Coop Vertical §10).
Чтобы дыра была заявленной, а не найденной через месяц, у каждого метода стоит <remarks> с
причиной, и её фиксирует тест Transactions_DoNotGoThroughTheBusYet.
Грабли: абстракции часов в проекте нет (DateTime.UtcNow берётся прямо в Game-слое), и заводить её
ради штампа времени — оверинжиниринг. Поэтому у шины два входа: глаголы, которые сами нумеруют и
штампуют, и Submit(in RunCommand) для готовой команды — им пользуются тест и будущая сеть, где у
приехавшей команды уже есть свой номер и своё время, и переприсваивать их значило бы потерять ровно то,
ради чего они существуют.
Второе: в лог попадает только то, что случилось. Команда, которой нечего было применять (слот вне ростера, пустой id), не логируется — иначе реплей повторял бы отказы как события.
Владелец правды: Guild/Commands/ целиком, Guild/AssemblyInfo.cs,
<remarks> у RunStateService.TrySpendGold; инварианты — RunCommandLogTests («один лог → один
RunState», идемпотентность по паре «игрок, номер», незалогированный отказ, граница транзакций).