GameFlowManager.Session.cs
using Sandbox;
using Sandbox.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
public sealed partial class GameFlowManager
{
[Property, Group( "Session Recovery" ), ResourceType( "scene" )]
public string OfflineRecoveryScene { get; set; } = "scenes/main.scene";
[Property, Group( "Online" ), Range( 3f, 30f )]
public float SearchRefreshSeconds { get; set; } = 5f;
// Developer-only opt-in in the second instance's console. Sandbox ignores it.
[ConVar( "omr_local_testing" )]
public static bool LocalTestingEnabled { get; set; }
private bool _hadConnection;
private bool _leaving;
//
// Two different host-exit cases must stay separate:
//
// 1) _normalHostCloseRequested:
// The host explicitly told us that the session is ending normally.
// The client should disconnect itself before the host destroys the lobby,
// so S&box never needs to show its native "host left" popup.
//
// 2) _hostClosedSession:
// Host migration / unexpected host loss. This remains an error/recovery
// path and keeps the existing user-facing connection-lost notice.
//
private bool _normalHostCloseRequested;
private bool _hostClosedSession;
//
// The host waits a very short grace period after broadcasting the normal
// close notification. This gives remote clients at least several frames to
// process the immediate RPC and disconnect themselves first.
//
private bool _hostDisconnectPending;
private float _hostDisconnectAt;
private bool _connectionAttempt;
private bool _rendezvousInProgress;
private float _nextRendezvousAt;
private string _pendingConnectionTarget;
private CancellationTokenSource _queryCancellation;
public bool IsLeavingSession => _leaving;
private static ulong LocalOwnerId => Connection.Local is null ? 0UL : (ulong)Connection.Local.SteamId;
protected override void OnDisabled()
{
CancelSearchWork();
base.OnDisabled();
}
private void CancelSearchWork()
{
_searchRequestId++;
_queryCancellation?.Cancel();
_queryCancellation?.Dispose();
_queryCancellation = null;
_searchInProgress = false;
_rendezvousInProgress = false;
}
private Task<List<LobbyInformation>> QueryDuelLobbies()
{
_queryCancellation?.Dispose();
_queryCancellation = new CancellationTokenSource();
_queryCancellation.CancelAfter( TimeSpan.FromSeconds( 15 ) );
return Networking.QueryLobbies( new Dictionary<string, string>
{
[LobbyKindKey] = DuelLobbyValue,
[LobbyStateKey] = SearchingLobbyValue
}, false, _queryCancellation.Token );
}
public void JoinLocalTestHost()
{
if ( !LocalTestingEnabled || _leaving || RoundManager is null ||
RoundManager.Mode != SessionMode.Menu )
return;
// Do not abandon somebody already connected to our own lobby.
if ( Networking.IsActive && (!Networking.IsHost || Connection.All.Count > 1) )
return;
OMRSceneFlow.SessionNotice = "";
BeginConnection( "local" );
}
private void BeginConnection( string target )
{
CancelSearchWork();
_pendingConnectionTarget = target;
_hadConnection = false;
_connectionAttempt = false;
_connectFallbackAt = 0f;
if ( Networking.IsActive && Networking.IsHost )
{
PublishDuelState( FullLobbyValue );
CleanupPlayerObjects();
}
Networking.Disconnect();
_hostedLobbyKind = HostedLobbyKind.None;
_lobbyDataPublished = false;
OnlineState = OnlineFlowState.Connecting;
}
private bool UpdateSessionLifecycle()
{
//
// Normal host shutdown is two-stage:
//
// 1. Notify remote clients immediately.
// 2. Let those clients disconnect themselves.
// 3. Disconnect the host once everybody else is gone, or after the
// short safety deadline expires.
//
// Keeping the lobby alive for these few frames prevents the engine from
// winning the race and showing its native "The host has left the game"
// popup during an intentional Return To Menu.
//
if ( _hostDisconnectPending )
{
if ( !Networking.IsActive )
{
_hostDisconnectPending = false;
return false;
}
bool clientsAreGone =
Connection.All.Count <= 1;
bool graceExpired =
RealTime.Now >= _hostDisconnectAt;
if ( clientsAreGone || graceExpired )
{
_hostDisconnectPending = false;
Log.Info(
clientsAreGone
? "SESSION | Remote clients left cleanly; host disconnecting"
: "SESSION | Graceful close deadline reached; host disconnecting"
);
Networking.Disconnect();
}
return true;
}
// A deliberate handover from our empty lobby to another lobby must not
// be mistaken for an unexpected disconnect. Connect only after teardown.
if ( _pendingConnectionTarget is not null )
{
if ( Networking.IsActive ) return true;
CleanupPlayerObjects();
string target = _pendingConnectionTarget;
_pendingConnectionTarget = null;
_connectionAttempt = true;
_connectFallbackAt = RealTime.Now + MathF.Max( ConnectFallbackDelay, 20f );
Log.Info( $"MATCHMAKING | Connecting to {target}" );
Networking.Connect( target );
return true;
}
if ( _connectionAttempt && Networking.IsActive && !Networking.IsConnecting )
{
_connectionAttempt = false;
_connectFallbackAt = 0f;
}
//
// Intentional match/session shutdown: leave locally while the host is
// still alive. Do not present this as a connection error.
//
if ( _normalHostCloseRequested && !_leaving )
{
_normalHostCloseRequested = false;
OMRSceneFlow.SessionNotice = "";
Log.Info(
"SESSION | Host requested normal close; disconnecting before lobby teardown"
);
DisconnectToOfflineMenu();
return true;
}
//
// Genuine host/session loss remains a recovery/error path.
//
if ( _hostClosedSession || (_hadConnection && !Networking.IsActive && !_leaving) )
{
_hostClosedSession = false;
OMRSceneFlow.SessionNotice = "The host left or the connection was lost. You can start a new session.";
Log.Info( "SESSION | Lost host/session; restoring offline menu" );
DisconnectToOfflineMenu();
return true;
}
if ( Networking.IsActive && !Networking.IsConnecting && !_leaving )
_hadConnection = true;
return false;
}
private void FailConnection()
{
_connectionAttempt = false;
OMRSceneFlow.SessionNotice = "Connection failed. Check that the host is waiting, then try again.";
DisconnectToOfflineMenu();
}
private void ReloadOfflineMenu()
{
if ( Networking.IsActive ) return;
_connectionAttempt = false;
_connectFallbackAt = 0f;
OMRSceneFlow.RequestGameplayEntry( OMRGameplayEntry.None );
// Loading the source scene restores scene/network ownership and fresh
// RoundManager caches after being a client. Never run while connected.
if ( !string.IsNullOrWhiteSpace( OfflineRecoveryScene ) &&
Scene.LoadFromFile( OfflineRecoveryScene ) )
return;
_leaving = false;
OMRSceneFlow.SessionNotice = "Could not reload the offline scene. Stop Play and reopen main.scene.";
Log.Warning( $"SESSION | Could not load recovery scene: {OfflineRecoveryScene}" );
}
[Rpc.Broadcast( NetFlags.HostOnly | NetFlags.Reliable | NetFlags.SendImmediate )]
private void NotifyHostClosing()
{
//
// Broadcast includes the host too; only remote clients need to react.
// HostOnly prevents a client from spoofing a normal session shutdown.
//
if ( Networking.IsHost )
return;
if ( Rpc.Caller != Connection.Host )
return;
_normalHostCloseRequested = true;
}
void Component.INetworkListener.OnBecameHost( Connection previousHost )
{
// NetworkHelper/editor sessions can allow host migration. OMR currently
// ends the session instead of continuing partially reset match state.
if ( previousHost is not null && !_leaving )
_hostClosedSession = true;
}
private void UpdateRendezvous()
{
if ( _leaving || _rendezvousInProgress || _searchInProgress ||
OnlineState != OnlineFlowState.WaitingForOpponent ||
!Networking.IsActive || Networking.IsConnecting || !Networking.IsHost ||
Connection.All.Count != 1 || RealTime.Now < _nextRendezvousAt )
return;
_nextRendezvousAt = RealTime.Now + MathF.Max( SearchRefreshSeconds, 3f );
_ = RendezvousAsync();
}
private async Task RendezvousAsync()
{
int request = _searchRequestId;
ulong localOwner = LocalOwnerId;
_rendezvousInProgress = true;
try
{
var lobbies = await QueryDuelLobbies();
if ( request != _searchRequestId || _leaving || !Networking.IsHost ||
!Networking.IsActive || Connection.All.Count != 1 ||
OnlineState != OnlineFlowState.WaitingForOpponent ) return;
// Only the higher owner ID yields. Two waiting hosts therefore never
// both disconnect in an attempt to join each other.
var candidate = lobbies.Where( IsJoinableDuelLobby )
.Where( x => x.OwnerId < localOwner ).OrderBy( x => x.OwnerId ).FirstOrDefault();
if ( candidate.OwnerId != 0 ) BeginConnection( candidate.LobbyId.ToString() );
}
catch ( OperationCanceledException ) { }
catch ( Exception exception )
{
if ( request == _searchRequestId )
Log.Warning( $"MATCHMAKING | Refresh failed; keeping lobby open: {exception.Message}" );
}
finally
{
if ( request == _searchRequestId ) _rendezvousInProgress = false;
}
}
}