Решили: гость не ведёт акт — он следует за хостом. Состав мероприятия у него собирается без петли акта, наград, магазина и резолвера узлов; где идёт игра и открыта ли арена, приезжает объявлением.

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

Объявляем СОСТОЯНИЕ, а не события «открылось» и «закрылось». Событие надо не пропустить, а гость подключается посреди игры и переживает потери: ему нужен ответ на «как сейчас», а не история того, как к этому пришли. Повтор того же состояния при этом ничего не стоит — применение идемпотентно.

Читаем состояние опросом, а не подпиской, и это выбор. Дверей к открытию боя несколько — узел забега, вход на Ристалище, дев-команда, — и подписка означала бы событие в каждой и обещание не забыть ни одной. Состояние же одно, читается двумя свойствами, а работа сводится к сравнению пяти полей раз в кадр.

Грабли, пойманные до того, как укусили: первый снимок и первое «где мы» сперва отправлялись гостю на рукопожатии — и ушли бы в пустоту. Приёмники у гостя рождаются ПОЗЖЕ: сеанс открывается, когда игрок уже принят и меню закрылось. Это тот самый класс дефекта «работает со второго раза», четырежды пойманный вечером 02.08. Починка — не «подождать», а перевернуть направление: гость просит сам, в момент готовности, пустым сообщением на том же канале.

Вход гостем прошит через меню, а не мимо него. Приглашение Steam доезжает до рукопожатия, и только тогда меню закрывается выбором JoinCoop — тем же приёмом, каким его закрывает dev-запрос Ристалища. Уйди мы из меню на «соединились», отказ («у вас другая версия контента») застал бы игрока уже в пустом мире, и возвращать его было бы некуда. Сам сеанс открывает верхний цикл игры, а не мост: у сеанса есть начало и конец, и открывать его должен тот, кто доведёт его до конца.

Вторые грабли, зеркальные вчерашним: Enum.IsDefined сверяет тип строго и бросает на несовпадении с основой перечисления. У команд забега основа байтовая (уронил int), у вида мероприятия и фазы боя — целочисленная (уронил byte). По проводу оба едут байтом, поэтому проверка обязана приводить к основе, а не к типу поля в пакете.

Чего этот заход НЕ закрывает. Цикл «хост открыл место → гость открыл такое же» тестом не покрыт: обе половины работают через ActivityHost, который рождает скоупы от сеанса, — это живая игра. До второго Steam-аккаунта проверяется только глазами. Тестом закрыт формат и правило отказа, то есть ровно то, что ломается молча.

Владелец правды: Game/Session/Net/ (ActivityBroadcast, GuestActivityFollower, CoopGuestEntry), Game/Activity/ActivityInstaller, Game/Services/GameFlow.PlayAsGuestAsync; тесты ActivityStateTests, GuestRunSyncTests.