Решили: INetTransport отвечает на вопрос «кто уже подключён» (ConnectedPeers), и все, кто ведёт
список участников — состав сеанса, гейт готовности, — спрашивают его на старте, а не только слушают
события.
Почему: транспорт поднимается на входе в игру, а сеанс рождается позже и пересоздаётся на каждой
смене режима (меню → кампания → площадка). Всё, что жило только на событиях PeerConnected, было
слепо к напарнику, пришедшему в этот промежуток: его не было ни в панели участников, ни в планке
«(N/M)», и хозяин узнавал о нём лишь тогда, когда тот отключался. Отсюда же и жалоба «пишет, что
игрок отключился, хотя о подключении не было ничего» — сообщение об уходе шло от транспорта, а список
участников о нём и не знал.
Отвергнуто «поднимать сеанс раньше транспорта» — порядок обратный по построению: роль сеанса
называют при входе в режим, а транспорт нужен до того, чтобы принять приглашение из оверлея Steam.
Отвергнуто «переигрывать события при рождении слушателя» — это журнал подключений, то есть второй
владелец факта, который транспорт и так знает точно.
Грабли: у Steam-транспорта «есть _client» не значит «соединение стоит»: между ConnectRelay и
OnConnected проходит время, поэтому гостевая половина ответа держится на отдельном признаке.
Добавление участника обязано быть идемпотентным — событие о том же пире придёт следом, уже после
опроса. У обеих половин оно и было (проверка в Add, HashSet в гейте), но полагаться на это молча
нельзя: инвариант закрыт тестом.
Владелец правды: Net/Transport/INetTransport.cs (ConnectedPeers), Game/Session/Net/HostSessionRoster.cs,
Game/Session/Net/HostReadyGate.cs; тест LateSessionSeesEarlyPeersTests.