Статус: ready — модель принята Максом 2026-07-27 и реализована целиком (фазы А–Ж). Зеркальная серия зелёная, EditMode 643/643

Дополнение 2026-07-28 (BAL-014): двухфазности для зеркальности оказалось НЕ достаточно — в SeparationSystem исход решал ещё и порядок сложения float. Тогдашняя «зелёная серия» держалась на [Ignore] у одного окна; атрибут снят, серия зелёная честно, EditMode 679. Правило и разбор — Journal - Why The Tick Order Is This Order §3.1a.


Как боевой тик разрешается как один момент, а не как очередь по списку юнитов. Документ отвечает на вопрос «кто что видит внутри одного тика» и фиксирует решения, принятые после того, как зеркальный бой трижды оказался решён порядком обработки.

Связано с Journal - Why The Tick Order Is This Order §3.1a–3.1b, Journal - Effects Are Ordered By Id, Attributed By Weight, боевая модель (код Assets/_Project/Scripts/Combat/).


1. Задача

Зеркальный бой — две одинаковые команды, отражённые по оси, — обязан заканчиваться ровным нулём. Это не эстетика: если сторона решает исход, врут все замеры баланса, а в игре одна из команд системно сильнее другой.

Замеры показали обратное: 59.7% против нуля. Причин оказалось не три и не пять — это один дефект, проявляющийся везде, где действия тика применяются последовательно:

Система читает мир и пишет в него внутри одного обхода. Кто в списке раньше — решает по нетронутому миру, кто позже — по уже изменённому. У зеркальных сторон порядок обратный, поэтому место в списке превращается в преимущество.

Найденные проявления (каждое подтверждено замером, не гипотеза):

ГдеКак проявилось
Фаза мышления ИИбралась из сквозного Id — команда, заспавненная первой, думала на тик раньше
Расталкиваниеписало сдвиг прямо в позицию по ходу обхода
Движениелевый целился во врага, который ещё не сходил; правый — в уже сдвинувшегося (Y −0.5566 против −0.5543 на первом тике)
Способностидва готовых криоманта: левый вешал «Оковы», правый ловил стан и терял каст навсегда
Авто-атакиослабление от раннего удара срезало удар, резолвящийся позже (146.9 против 102.8 = ровно ×0.7)
Лечениехил левому упирался в потолок HP раньше, чем по нему прилетал урон: 7.3 вместо 80 (разрыв 72.6 HP из ничего)
Телепорт-блинкточка «за спиной» считалась от позиций, уже сдвинутых ударами того же тика
Порядок эффектову левого […, airborne, hold], у правого […, hold, airborne] — каждый вешает своё раньше чужого

2. Принятая модель

2.1 Инвариант двухфазных систем (HARD)

Система, которая читает чужое состояние и пишет своё, обязана быть двухфазной: намерения от общего снимка → применение после обхода. Реализовано в MovementSystem, AbilitySystem, AutoAttackSystem, SeparationSystem.

2.2 Закон видимости эффектов

Наложенный эффект меняет статы и маску тегов носителя не раньше конца тика (шаг CommitEffects) — как это давно делают флаги контроля. Исключение — pre-damage реактивы («Оплот» ловит тот самый удар, что его разбудил), они синхронны сознательно.

2.3 Урон и лечение применяются разом

Урон, лечение и поглощение щитом копятся за тик и применяются одним коммитом. Все развилки решены Максом 2026-07-27 — ниже полный список, чтобы будущий заход спорил с документом, а не с памятью:

ВопросРешениеПочему
Смертельный урон и лечение в один тикСкладывать дельты: HP += лечение − урон, затем клампЧистая одновременность: успевший хилер спасает. Проще всего объяснить игроку и полностью снимает зависимость от порядка
Несколько ударов по щитуСложить урон, вычесть из щита разом, остаток — в HPДля чистого поглощения математически эквивалентно последовательному, но результат не зависит от порядка
Двое лечат одного, места меньше суммыПропорционально вкладуНикто не наказан за то, что его хил обработали вторым. Важно для вампиризма и on-heal реактивов, которые считают ФАКТИЧЕСКИ вылеченное
Шипы и вампиризмВ том же тике, раундами с капомОтветка остаётся мгновенной (ощущение боя не меняется); симметрию даёт то, что каждый раунд сам двухфазный
Сумма добила цель — кому убийствоНаибольшей доле урона, тай-брейк по IdУ смерти один владелец, награды за неё не множатся (рестелс убийцы, килл-слоумо, метрики). Тай-брейк зеркалу не вредит: сравниваются только те, кто бил одну и ту же цель
Цель погибла в первом раунде — отвечают ли её шипы во второмДа, отвечаютDeathSystem работает последней, весь тик боец считается живым: умирая, древень успевает уколоть в ответ — взаимный размен возможен
На что вообще срабатывают шипыТолько на прямое попадание — авто-атака или способностьШипы отвечают удару, а не тлеющему урону: тик яда и горение проходят мимо, и шипы не отвечают шипам. Гейт по CombatEventData.IsDirectHit закрывает пинг-понг в корне

2.4 Чего модель НЕ меняет

  • Порядок систем за тик — прежний (плюс новый шаг CommitEffects).
  • Смерть по-прежнему в конце тика: взаимное убийство остаётся возможным.
  • SimConstants.TickRate = 30 Гц, ICombatContext как единственный шов мутаций.
  • RNG и детерминизм: модель их не трогает, но делает воспроизводимость честнее.

3. Фазы

ФазаЧтоСтатус
АДвухфазное движениесделано
БДвухфазные способности (решение → каст)сделано
ВДвухфазные авто-атаки (снятие цифр → прилёт) + закон видимости эффектовсделано
ГTickLedger: урон, лечение и щит — одним коммитомсделано
ДРаунды реактивов поверх реестра (ResolveCombatRounds)сделано
ЕПереходы «за спину» отдельной фазой, за швом ICombatContext.TeleportBehindсделано
ЖПорядок ActiveEffects нормализован по id эффектасделано

Что вскрылось по дороге (и вошло в модель)

  • Шипы не отвечали шипам только на словах. Докстринга ThornsComponent утверждала, что ответка помечена Reactive и потому пинг-понга нет, — а гейта в коде не было, и цепочка реально ходила по кругу, гаснув лишь капом. Гейт поставлен (IsDirectHit), заодно по правилу Макса шипы перестали реагировать на тики DoT. Классический дрейф док↔код: доке верили, код не проверяли.
  • Порог значимости заявки (TickLedger.MinSignificantAmount = 0.05 HP). Взаимные шипы затухали геометрически, но нуля не достигали: ряд упирается в предел числа, а не в конец боя. После гейта выше цепочки нет вовсе, но порог и кап (16) оставлены страховкой для будущего контента с отражением отражения; при исчерпании кап предупреждает, а не ругается ошибкой — длинный ряд не обязательно баг.
  • Хэш врал после блинка. Телепорт двигает тело ПОСЛЕ перестройки SpatialHash, поэтому до конца тика сетка не соответствовала позициям, и соседи находили блинкнувшего по-разному с двух сторон (замер: одинаковые отряды разъезжались на тике 29). AutoAttackSystem.Tick теперь возвращает признак «тела двигались», и симуляция перестраивает хэш повторно — но только когда сдвиг реально был.
  • Порядок эффектов хранил историю, а не состояние. Каждый боец вешал своё раньше чужого, поэтому у зеркальных монахов набор совпадал, а очередь — нет. Список упорядочен по id: на цели эффект живёт в одном экземпляре на определение, значит ключ полный. Вставкой при наложении, а не сортировкой на тике.
  • Переход «за спину» стал заявкой. VortexEntryComponent телепортировал прямо посреди доставки событий, и встречный заход второго монаха целился в уже уехавшее тело (X −8.20 против −8.70 вместо −8.20 против +8.20). Заявки копятся, точки считаются от общего снимка, применяются разом.

4. Сторожа

  • MirrorMatchTests — серия зеркальных боёв: скользящее окно составов по всему ростеру, каждый кит против себя, три строя. Критерий строгий: сверка каждый тик, а не итог боя. Прежняя редакция сравнивала остатки HP в конце с допуском 10 п.п. и молчала, пока расхождение не разрасталось до разгрома.
  • MirrorDivergenceProbe ([Explicit]) — диагностика: один показательный отряд на полный бой, печатает первый расходящийся тик, слепок обеих сторон и удары того тика.
  • Общая линейка — MirrorFixture.FirstDifference: HP, позиция (X отражён, Y равен), щит, ресурс, кулдауны, фаза свинга, эффекты со стаками и остатком.

5. Открытые вопросы

  1. Протечки шва. Статы и ресурс по-прежнему правятся мимо ICombatContext (позиция «за спину» уже за швом — фаза Е). Все пойманные причины выросли именно в непокрытых клетках, поэтому реестр протечек и решение по каждой — отдельный заход.
  2. Перезамер баланса. Модель меняет числа: лечение больше не пропадает в потолок, размены честнее, убийство при одновременном уроне достаётся наибольшей доле. Замеры стенда надо переснять.
  3. Порог значимости взят инженерным решением (0.05 HP). Если контент заведёт механику, которая осмысленно тикает мельче, порог придётся пересмотреть — сейчас такой нет.