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