using System; using System.Threading.Tasks; using Unity.Netcode; using Unity.Netcode.Transports.UTP; using Unity.Services.Authentication; using Unity.Services.Core; using Unity.Services.Multiplayer; using UnityEngine; using System.Collections.Generic; using System.Linq; using ProjectI.Builds; using ProjectI.Combat; namespace ProjectI.Sessions { public sealed class PrototypeSessionController : MonoBehaviour { public static PrototypeSessionController Instance { get; private set; } [SerializeField] private NetworkManager networkManager; [SerializeField] private PrototypeArenaState arenaState; [SerializeField] private bool useMainMenu; private bool cancelRequested; private bool connecting; private bool connectionFailed; private Task cleanupTask; public bool CanCancelConnection=>connecting&&!cancelRequested&&!closing; public bool UseMainMenu=>useMainMenu; public NetworkManager Network=>networkManager; public GameSceneFlow SceneFlow {get;private set;} public BuildLoadout LocalBuild=>LocalBuildPreference.Selected; public int LocalFirstWeapon=>LocalWeaponPreference.First; public int LocalSecondWeapon=>LocalWeaponPreference.Second; public IReadOnlyList Roster=>PrototypeMenuLobby.Instance?.Roster??Array.Empty(); public bool LocalReady=>networkManager!=null&&Roster.Any(x=>x.OwnerId==networkManager.LocalClientId&&x.Ready); public bool RunLoadoutsLocked=>useMainMenu&&(PrototypeMenuLobby.Instance?.Locked??false); public bool CanStart=>!Busy&&IsHost&&!RunStarted&&(!useMainMenu||PrototypeMenuLobby.Instance!=null&&PrototypeMenuLobby.Instance.CanStart(out _)); public string StartBlockedReason { get { if(Busy)return "A session operation is in progress."; if(!IsHost)return "Only the host can start the run."; if(RunStarted)return "The run has already started."; if(!useMainMenu)return ""; if(PrototypeMenuLobby.Instance==null)return "Waiting for the lobby state."; PrototypeMenuLobby.Instance.CanStart(out var reason);return reason; } } public void SubmitLocalLoadout(BuildLoadout build,int first,int second) { if(RunLoadoutsLocked||RunStarted||first==second||WeaponRoster.Get(first)==null||WeaponRoster.Get(second)==null)return; LocalBuildPreference.Selected=build;LocalWeaponPreference.First=first;LocalWeaponPreference.Second=second; if(useMainMenu&&Connected)PrototypeMenuLobby.Instance?.Submit(build,first,second); Changed?.Invoke(); } public void SetReady(bool ready){if(useMainMenu&&Connected&&!Busy)PrototypeMenuLobby.Instance?.SetReady(ready);} public void NotifyLobbyChanged()=>Changed?.Invoke(); public void ConfigureDevelopmentEntry(){useMainMenu=false;} public Task CancelConnectionAsync() { if(!CanCancelConnection)return Task.CompletedTask; cancelRequested=true; return useMainMenu?SceneFlow.ReturnToMenuAsync("Connection cancelled. Host or join when ready."):Task.CompletedTask; } private readonly SpawnSlotAllocator spawnSlots = new SpawnSlotAllocator(); private readonly SessionLifecycle lifecycle = new SessionLifecycle(); private ISession session; private bool localDebugSession; private bool localTransportSaved; private UnityTransport.ConnectionAddressData previousLocalAddress; public bool IsLocalDebugSession => localDebugSession; private bool closing; private bool admissionClosed; private bool destroyed; private readonly SessionRosterRecovery rosterRecovery = new SessionRosterRecovery(); private bool refreshingRoster; private uint sessionGeneration; public int RosterRefreshes { get; private set; } private static readonly Vector3[] SpawnPositions = { new Vector3(-6f, 0.1f, -6f), new Vector3(6f, 0.1f, -6f), new Vector3(-6f, 0.1f, 6f), new Vector3(6f, 0.1f, 6f) }; public event Action Changed; public bool Busy => lifecycle.Busy||(SceneFlow!=null&&SceneFlow.Transitioning); public string Status { get; private set; } = "Host a game or enter a friend's join code."; public string JoinCode => session?.Code ?? ""; public bool Connected => (session != null || localDebugSession) && networkManager!=null&&networkManager.IsConnectedClient; public bool IsHost => Connected && networkManager.IsHost; public bool RunStarted => ProjectI.Runs.PrototypeRunDirector.Instance != null ? ProjectI.Runs.PrototypeRunDirector.Instance.Phase != ProjectI.Runs.RunPhase.Menu : arenaState != null && arenaState.RunStarted; public int PlayerCount => localDebugSession&&networkManager!=null&&networkManager.IsServer?networkManager.ConnectedClientsIds.Count:session?.Players.Count ?? 0; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); SceneFlow=GetComponent();if(SceneFlow==null)SceneFlow=gameObject.AddComponent(); networkManager.ConnectionApprovalCallback = ApproveConnection; networkManager.OnClientConnectedCallback += OnConnected; networkManager.OnClientDisconnectCallback += OnDisconnected; Application.runInBackground = true; } public async Task HostAsync() { if(SceneFlow!=null)await SceneFlow.DevelopmentEntryReady; if (Busy || Connected) return; BeginOperation("Creating a private game…"); connecting=true; uint operation=++sessionGeneration;cancelRequested=false;connectionFailed=false;rosterRecovery.Reset(); admissionClosed = false; spawnSlots.Clear(); try { await InitializeServicesAsync(); var createdSession = await MultiplayerService.Instance.CreateSessionAsync(new SessionOptions { Name = "Prototype Arena", MaxPlayers = SessionRules.MaxPlayers, IsPrivate = true }.WithRelayNetwork()); if(operation!=sessionGeneration||cancelRequested) {await CleanupAsync();session=createdSession;await CleanupAsync();return;} session=createdSession; await FinishConnectionAsync(); } catch (Exception exception) { await ConnectionFailedAsync(exception); } finally { if(cancelRequested&&!connectionFailed&&useMainMenu&&!destroyed)await SceneFlow.ReturnToMenuAsync("Connection cancelled. Host or join when ready."); connecting=false;await EndOperationAsync(); } } public async Task JoinAsync(string code) { if(SceneFlow!=null)await SceneFlow.DevelopmentEntryReady; if (Busy || Connected) return; string normalized = SessionRules.NormalizeJoinCode(code); if (normalized == null) { SetStatus("Enter a join code using letters and numbers."); return; } BeginOperation("Joining your friend's game…"); connecting=true; uint operation=++sessionGeneration;cancelRequested=false;connectionFailed=false;rosterRecovery.Reset(); try { await InitializeServicesAsync(); var joinedSession = await MultiplayerService.Instance.JoinSessionByCodeAsync(normalized, new JoinSessionOptions()); if(operation!=sessionGeneration||cancelRequested) {await CleanupAsync();session=joinedSession;await CleanupAsync();return;} session=joinedSession; await FinishConnectionAsync(); } catch (Exception exception) { await ConnectionFailedAsync(exception); } finally { if(cancelRequested&&!connectionFailed&&useMainMenu&&!destroyed)await SceneFlow.ReturnToMenuAsync("Connection cancelled. Host or join when ready."); connecting=false;await EndOperationAsync(); } } public async Task HostLocalDebugAsync() { if(SceneFlow!=null)await SceneFlow.DevelopmentEntryReady; if(!ProjectI.Debugging.DebugArenaEntry.InArena && !ProjectI.Debugging.PlaytestSceneController.InScene) {SetStatus("Local testing requires a configured development test scene.");return;} if(Busy||Connected||networkManager==null||networkManager.IsListening)return; BeginOperation("Starting local test host…"); connecting=true;cancelRequested=false;connectionFailed=false; ++sessionGeneration;rosterRecovery.Reset();spawnSlots.Clear();admissionClosed=false; try { if(networkManager.ShutdownInProgress)throw new InvalidOperationException("Network shutdown is still pending."); var transport=networkManager.NetworkConfig.NetworkTransport as UnityTransport; if(transport==null)throw new InvalidOperationException("Local testing requires Unity Transport."); previousLocalAddress=transport.ConnectionData;localTransportSaved=true; // The next online operation configures a fresh Relay allocation through the SDK. transport.SetConnectionData(true,"127.0.0.1",7777,"127.0.0.1"); localDebugSession=true;useMainMenu=false; if(!networkManager.StartHost()||!networkManager.IsConnectedClient)throw new InvalidOperationException("Local host could not start."); SetStatus("Local test host connected. Select a scenario."); } catch(Exception error){await CleanupAsync();SetStatus("Local host failed: "+error.Message);} finally{connecting=false;await EndOperationAsync();} } private async Task InitializeServicesAsync() { if (UnityServices.State != ServicesInitializationState.Initialized) { string profile = CommandLineValue("--profile") ?? "player"; await UnityServices.InitializeAsync(new InitializationOptions().SetProfile(profile)); } if (!AuthenticationService.Instance.IsSignedIn) await AuthenticationService.Instance.SignInAnonymouslyAsync(); await lifecycle.RetryCleanupAsync(); if (networkManager.ShutdownInProgress) throw new InvalidOperationException("Network shutdown is still pending."); } private async Task FinishConnectionAsync() { if (destroyed || lifecycle.ConnectionLossPending || !networkManager.IsConnectedClient) { connectionFailed=true; await CleanupAsync(); if(!destroyed&&useMainMenu)await SceneFlow.ReturnToMenuAsync("The connection ended. Check the code and try again."); if (!destroyed) SetStatus("The connection ended. Check the code and try again."); return; } session.Changed += OnSessionChanged; session.Deleted += OnSessionEnded; session.RemovedFromSession += OnSessionEnded; SetStatus(useMainMenu?"Connected. Equip a loadout and select Ready.":"Connected. Explore the arena; the host can start the run."); Debug.Log("Foundation connected: " + (networkManager.IsHost ? "host" : "client")); } public async Task StartRunAsync() { if(!CanStart){SetStatus(StartBlockedReason);return;} if(useMainMenu&&!PrototypeMenuLobby.Instance.LockStartServer()){SetStatus("The lobby changed. Every player must be ready.");return;} BeginOperation("Closing admission and starting the run…"); admissionClosed = true; try { if(!localDebugSession) { var hostSession = session.AsHost(); hostSession.IsLocked = true; await hostSession.SavePropertiesAsync(); } if (!destroyed && Connected) { if(useMainMenu) { await SceneFlow.EnterRunAsync(); if(Connected)SetStatus("Run started. New players cannot join."); return; } if (arenaState == null) arenaState = UnityEngine.Object.FindAnyObjectByType(); arenaState.StartRun(); ProjectI.Runs.PrototypeRunDirector.Instance?.StartRunServer(); SetStatus("Run started. New players cannot join."); } } catch (Exception) { admissionClosed = false; PrototypeMenuLobby.Instance?.UnlockStartServer(); if (session != null && session.IsHost) session.AsHost().IsLocked = false; SetStatus("Could not start the run. Please try again."); } finally { await EndOperationAsync(); } } public Task LeaveAsync() { return LeaveAsync("You left the game. Host or join another game."); } private async Task LeaveAsync(string message) { if((useMainMenu||ProjectI.Debugging.DebugArenaEntry.InArena)&&SceneFlow!=null){await SceneFlow.ReturnToMenuAsync(message);return;} if (Busy || closing || destroyed) return; BeginOperation("Leaving the game…"); try { await CleanupAsync(); SetStatus(message + (lifecycle.HasPendingCleanup ? " Online cleanup is pending; retry after the connection returns." : "")); } finally { await EndOperationAsync(); } } internal async Task CleanupForSceneReturnAsync(string reason) { cancelRequested=true; await CleanupAsync(); useMainMenu=true; if(!lifecycle.Busy)lifecycle.EndOperation(); if(!destroyed)SetStatus(reason+(lifecycle.HasPendingCleanup?" Online cleanup is pending; retry after the connection returns.":"")); } private Task CleanupAsync() { if(cleanupTask!=null)return cleanupTask; var completion=new TaskCompletionSource();cleanupTask=completion.Task; _=CleanupAndCompleteAsync(completion);return completion.Task; } private async Task CleanupAndCompleteAsync(TaskCompletionSource completion) { Exception failure=null; try{await CleanupCoreAsync();}catch(Exception exception){failure=exception;} finally{cleanupTask=null;} if(failure==null)completion.TrySetResult(true);else completion.TrySetException(failure); } private async Task CleanupCoreAsync() { sessionGeneration++; rosterRecovery.Reset(); closing = true; var previousSession = session; session = null; if (previousSession != null) { previousSession.Changed -= OnSessionChanged; previousSession.Deleted -= OnSessionEnded; previousSession.RemovedFromSession -= OnSessionEnded; lifecycle.PreserveCleanup(async () => { if (previousSession.State == SessionState.Deleted) return; if (previousSession.IsHost) await previousSession.AsHost().DeleteAsync(); else await previousSession.LeaveAsync(); }, () => previousSession.State == SessionState.Deleted || !MultiplayerService.Instance.Sessions.TryGetValue(previousSession.Type, out var registered) || !ReferenceEquals(previousSession, registered), async () => { // The SDK may already have removed membership. Retire its local handler without another cloud leave. var handler = previousSession.Network?.NetworkHandler; if (handler != null) await handler.StopAsync(); }, SessionCleanupPolicy.IsTerminalError); } try { // Sessions owns normal network teardown; preserve failed cloud cleanup for retry. await lifecycle.RetryCleanupAsync(); } catch (Exception exception) { Debug.Log("Cloud cleanup could not finish: " + exception.GetType().Name); } finally { if (networkManager != null && networkManager.IsListening && !networkManager.ShutdownInProgress) networkManager.Shutdown(); var shutdownTimer = System.Diagnostics.Stopwatch.StartNew(); while (!destroyed && networkManager != null && networkManager.ShutdownInProgress && shutdownTimer.Elapsed.TotalSeconds < 5) await Task.Delay(25); localDebugSession=false; if(localTransportSaved&&networkManager!=null&&networkManager.NetworkConfig.NetworkTransport is UnityTransport transport) {transport.SetConnectionData(false,previousLocalAddress.Address,previousLocalAddress.Port,previousLocalAddress.ServerListenAddress);transport.ConnectionData=previousLocalAddress;localTransportSaved=false;} spawnSlots.Clear(); ProjectI.Runs.PrototypeRunDirector.ResetRuntime(); admissionClosed = false; closing = false; Cursor.lockState = CursorLockMode.None; Cursor.visible = true; } } private async Task ConnectionFailedAsync(Exception exception) { connectionFailed=true; await CleanupAsync(); if (destroyed) return; string reason = exception is SessionException sessionException ? sessionException.Error.ToString() : exception.GetType().Name; if(useMainMenu)await SceneFlow.ReturnToMenuAsync("Connection failed ("+reason+"). Check the code and try again."); SetStatus("Connection failed (" + reason + "). Check the code, internet connection, and service setup; then retry."); Debug.Log("Foundation connection failed: " + reason); } private void ApproveConnection(NetworkManager.ConnectionApprovalRequest request, NetworkManager.ConnectionApprovalResponse response) { bool allowed = SessionRules.CanAdmit(networkManager.ConnectedClients.Count, admissionClosed || RunStarted) && spawnSlots.TryReserve(request.ClientNetworkId, out _); response.Approved = allowed; response.CreatePlayerObject = allowed&&!useMainMenu; response.Pending = false; if (!allowed) { response.Reason = admissionClosed || RunStarted ? "The run has started." : "The game is full."; return; } spawnSlots.TryReserve(request.ClientNetworkId, out int slot); if(!useMainMenu&&!ProjectI.Runs.StageSpatialContext.TrySpawn(slot,out var supportedSpawn)) {response.Approved=false;response.CreatePlayerObject=false;response.Reason="Stage spawn is unavailable.";spawnSlots.Release(request.ClientNetworkId);return;} response.Position = useMainMenu?Vector3.zero:ProjectI.Runs.StageSpatialContext.TrySpawn(slot,out var stageSpawn)?stageSpawn:SpawnPositions[slot]; Vector3 facing = -(response.Position??Vector3.zero); facing.y = 0f; response.Rotation = facing.sqrMagnitude>.000001f?Quaternion.LookRotation(facing):Quaternion.identity; } private void OnConnected(ulong clientId) { Changed?.Invoke(); } private void Update() { if (refreshingRoster) return; bool allowed = !destroyed && !closing && !Busy && !localDebugSession && IsHost && session != null && session.State != SessionState.Deleted; int networkCount = allowed ? networkManager.ConnectedClientsIds.Count : 0; if (rosterRecovery.TryBegin(networkCount, PlayerCount, allowed, Time.unscaledTimeAsDouble)) _ = RefreshRosterAsync(session, sessionGeneration); } private async Task RefreshRosterAsync(ISession expected, uint generation) { refreshingRoster = true; RosterRefreshes++; try { await expected.RefreshAsync(); if (destroyed || closing || generation != sessionGeneration || !ReferenceEquals(session, expected)) return; Changed?.Invoke(); Debug.Log("Session roster snapshot refreshed after a stable network/service mismatch."); } catch (Exception exception) { if (!destroyed && generation == sessionGeneration) Debug.Log("Session roster refresh deferred: " + exception.GetType().Name); } finally { refreshingRoster = false; rosterRecovery.Complete(); } } private void OnSessionChanged() { Changed?.Invoke(); } private async void OnDisconnected(ulong clientId) { spawnSlots.Release(clientId); Changed?.Invoke(); if (closing || destroyed || clientId != networkManager.LocalClientId) return; if (Busy) { lifecycle.RequestConnectionLoss(); return; } await LeaveAsync("The host or connection was lost. Host or join another game."); } private async void OnSessionEnded() { if (closing || destroyed) return; if (Busy) { lifecycle.RequestConnectionLoss(); return; } await LeaveAsync("The game ended. Host or join another game."); } private void BeginOperation(string message) { lifecycle.BeginOperation(); SetStatus(message); } private async Task EndOperationAsync() { bool lost = lifecycle.EndOperation(); if (destroyed) return; Changed?.Invoke(); if (lost && (session != null||localDebugSession) && !closing) await LeaveAsync("The host or connection was lost. Host or join another game."); } private void SetStatus(string message) { Status = message; if (!destroyed) Changed?.Invoke(); } public static string CommandLineValue(string flag) { string[] arguments = Environment.GetCommandLineArgs(); for (int i = 0; i < arguments.Length - 1; i++) if (arguments[i] == flag) return arguments[i + 1]; return null; } private void OnDestroy() { if (Instance != this) return; Instance = null; destroyed = true; if (session != null) { session.Changed -= OnSessionChanged; session.Deleted -= OnSessionEnded; session.RemovedFromSession -= OnSessionEnded; } if (networkManager == null) return; networkManager.OnClientConnectedCallback -= OnConnected; networkManager.OnClientDisconnectCallback -= OnDisconnected; networkManager.ConnectionApprovalCallback = null; } } }