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;
		}
	}
}