Решили: чанк боевой ленты самодостаточен и несёт одну секунду показа: дельта считается только внутри него, первое появление юнита в чанке пишется целиком. Раздача идёт потоком по ходу боя, а не «весь бой перед показом».

Почему self-contained: непрерывная дельта через границы чанков экономит проценты трафика и делает потерю ОДНОГО чанка причиной поломки всех следующих. Самодостаточный теряет ровно то, что потерялось. Цена — полный кадр раз в секунду, и она уже заложена в оценку ~3.3 КБ/с при двенадцати юнитах.

Два разных обещания точности, и смешивать их нельзя. Числа СОБЫТИЙ (урон, лечение) едут полным float и обязаны совпасть бит в бит: они становятся цифрами на экране и строками аудита. СНИМКИ квантуются (позиция 1/256 мировой единицы, шкалы в ushort), и обещание у них другое — «в пределах шага упаковки». Тест, требующий побайтового совпадения от снимков, заставил бы отказаться от квантования и утроил бы трафик.

Сказано: «ты так задаешь весело. А если бой дольше будет? Если он очень долгий? Или увеличим время боя в будущем?» (01.08.2026, сессия 9e6eef1b). Вопрос вскрыл настоящую ошибку в ТЗ §5.3 — см. ниже.

Раздача «весь бой, потом показ» была нереализуема, и не из-за трафика. Окно снимков в ленте — 12 секунд (BattleTapeRecorder.DefaultWindowTicks = (10+2)×30). Сим, уехавший до конца боя, вытесняет снимки первых секунд, и раздавать становится нечего: для любого боя длиннее двенадцати секунд путь не работает вовсе. Растить окно под длину боя — тупик (пять минут ≈ 4.5 МБ живой памяти ради данных, нужных один раз). Поток решает вопрос целиком: длина боя не влияет ни на трафик (битрейт постоянный, ~26 кбит/с), ни на память у гостя (снимки ложатся в такое же кольцо и вытесняются показанными). Требование пари соблюдено: запрет был на «увидеть ленту до закрытия ставок», а не на «раздавать по ходу».

Грабли:

  • AbilityData нет в реестре контента вовсе — это [Serializable]-класс внутри UnitData, а не ScriptableObject со своим ассетом. Свой Id у неё есть, а дома в базе нет, поэтому IContentDatabase.TryGet<AbilityData> не найдёт её никогда (и не компилируется: она не ContentDefinition). Адресуется только через индекс, собранный из способностей всех юнитов (TapeAbilityIndex).
  • BattleTape умела принимать состояние только с живых RuntimeUnit (CaptureTick). У клиента симуляции нет вовсе, поэтому приёму понадобилась своя дверь — CaptureSnapshots. Один метод на два случая заставил бы гостя поднимать сим, который он не считает.
  • Первый прогон уронил мой же тест, и он был прав. Я искала урон по индексу 0, а сортировка ленты по паре (тик, доля) поставила лечение с долей 0 раньше удара с долей 0.5 — то есть инвариант из sub-tick-захода сработал и через дорогу. Порядок теперь проверяется явно: у гостя вспышка и цифра не имеют права поменяться местами.

Владелец правды: Net/Tape/ целиком (TapeChunkFormat — числа формата, TapeQuantization — шаги упаковки, TapeChunkWriter/TapeChunkReader, TapeAbilityIndex), BattleTape.CaptureSnapshots; инварианты — TapeChunkCodecTests (события точно, снимки в пределах шага, PreviousPosition восстанавливается из прошлого кадра, неизменный юнит почти бесплатен, дубль чанка применяется один раз, чужая версия и обрезанный чанк отвергаются, сверх потолка — отказ на нашей стороне).