Решили: чанк боевой ленты самодостаточен и несёт одну секунду показа: дельта считается только внутри него, первое появление юнита в чанке пишется целиком. Раздача идёт потоком по ходу боя, а не «весь бой перед показом».
Почему 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
восстанавливается из прошлого кадра, неизменный юнит почти бесплатен, дубль чанка применяется один раз,
чужая версия и обрезанный чанк отвергаются, сверх потолка — отказ на нашей стороне).