Решили: раздачу боевой ленты развели на две стороны — TapeStreamer у хоста режет готовые тики на чанки и шлёт их, TapeIntake у гостя складывает их в свою ленту и просит повторить недоехавшие по номеру. Поверх транспорта появился конверт: первый байт сообщения объявляет канал (NetEnvelope, NetChannel).

Почему: три решения внутри, каждое с отвергнутой альтернативой.

  1. Готовность подаёт вызывающий (Pump(readyThroughTick)), а не стример спрашивает симуляцию. Альтернатива «стример знает про CombatSimulation» связала бы раздачу с боем и потребовала бы симуляции в каждом тесте; заодно правило «что игроку уже можно видеть» осталось бы в двух головах.
  2. Хвост короче чанка уезжает только по Flush. Если досылать неполный чанк на каждом кадре, конец боя раздробится на однотиковые посылки — а в них едет исход, самое важное событие ленты.
  3. Потеря лечится повтором по номеру, а не непрерывной дельтой. Чанк самодостаточен (решение от кодека), поэтому повтор — это те же байты из кольца на 32 чанка, а переупорядочивание и дубли не значат ничего. Сплошная дельта через границы чанков дала бы на проценты меньше трафика ценой «потеряли один — сломались все следующие».

Грабли: ChaosTransport намеренно не теряет надёжные сообщения — их доставку обеспечивает транспорт, и терять их значило бы моделировать не сеть, а сломанный транспорт. Случай «чанк не доехал» бывает на разрыве соединения, и проверять его пришлось своим DroppingTransport внутри теста. Полчаса ушло на попытку выкрутить это профилем chaos, которого там нет по замыслу.

Второе: номер чанка писатель тратит только когда действительно пишет — на пустом диапазоне (лента чистилась, бой не начинался) Write выходит до инкремента. Не заметив этого, легко завести у гостя вечную дыру, которую он будет просить повторить до конца боя.

Владелец правды: Net/NetEnvelope.cs (канал и конверт), Net/Tape/TapeStreamer.cs, Net/Tape/TapeIntake.cs; тесты TapeDeliveryTests — в них же живёт обещание «у гостя тот же бой» при переупорядочивании, дублях и потере.