using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Unity.Netcode; using Unity.Services.Authentication; using Unity.Services.Core; using Unity.Services.Multiplayer; using UnityEngine; /// /// Online hosting/joining over Unity Gaming Services (unified Multiplayer "Sessions" API: Relay + Lobby + /// anonymous Authentication behind single calls). The Sessions API configures and starts the NGO transport /// itself, so we do NOT call /StartClient here - /// CreateSessionAsync starts the host, JoinSession* starts the client; the usual NetworkManager callbacks /// then fire exactly as for loopback hosting, so MatchManager, spawning and authority are unaffected. /// Note the UGS "session" (the public server-list entry) is distinct from the in-game "Lobby" scene. /// /// Logic layer only, surfaced through the / events; the /// uGUI browser driving it is . Requires the project to be linked to a /// UGS cloud project (Project Settings > Services) with Relay, Lobby and Authentication (Anonymous) /// enabled; otherwise sign-in fails with a clear status message. /// [RequireComponent(typeof(NetworkManager))] [DisallowMultipleComponent] public class OnlineSessionManager : MonoBehaviour { [Tooltip("Maximum players per hosted session (host included).")] public int maxPlayers = 8; [Tooltip("Give up on a hosting/joining/sign-in attempt that hangs longer than this many seconds (0 = wait forever).")] public float connectTimeoutSeconds = 25f; private NetworkManager _net; private ISession _session; // active session (host or client); null when not in one private List _sessions = new List(); private string _hostName = ""; private string _status = "Idle"; private bool _busy; private bool _leaving; // suppress the defensive leave while an explicit leave is in flight /// Raised when status, busy state, or the connection state changes, so a bound UI can re-render. /// Async continuations resume on the main thread, so handlers run there. public event Action Changed; /// Raised after the browsable session list is replaced (a refresh completed). public event Action SessionsChanged; public string Status => _status; public bool IsBusy => _busy; public bool IsConnected => _net && _net.IsListening; public IReadOnlyList Sessions => _sessions; private void Awake() { _net = GetComponent(); _hostName = $"{SystemInfo.deviceName} game"; } private void OnEnable() { if (!_net) return; _net.OnServerStarted += OnNetworkStarted; _net.OnClientStarted += OnNetworkStarted; _net.OnServerStopped += OnNetworkStopped; _net.OnClientStopped += OnNetworkStopped; } private void OnDisable() { if (!_net) return; _net.OnServerStarted -= OnNetworkStarted; _net.OnClientStarted -= OnNetworkStarted; _net.OnServerStopped -= OnNetworkStopped; _net.OnClientStopped -= OnNetworkStopped; } private void OnNetworkStarted() => Changed?.Invoke(); // The match-end / disconnect / Shutdown paths stop the NetworkManager without telling UGS. Leave the // session here too so the Relay allocation and the public lobby entry are released. private void OnNetworkStopped(bool _) { var stale = _session; _session = null; if (stale != null && !_leaving) LeaveSilently(stale); Changed?.Invoke(); } private async void LeaveSilently(ISession session) { try { await session.LeaveAsync(); } catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); } } // ----- Public API (driven by OnlineSessionBrowserUI) ------------------------------------------------- public void Host() => HostAsync(); public void Refresh() => RefreshAsync(); public void Join(string sessionId) => JoinAsync(sessionId); public void Disconnect() => DisconnectAsync(); // ----- Implementation -------------------------------------------------------------------------------- private void SetStatus(string status) { _status = status; Changed?.Invoke(); } private void SetBusy(bool busy) { _busy = busy; Changed?.Invoke(); } private async Task EnsureSignedInAsync() { if (UnityServices.State != ServicesInitializationState.Initialized) { // Anonymous tokens are cached per machine under company/product, NOT per project path - so // junction-mirror clones (shared ProjectSettings) would otherwise resolve to one UGS player id // and collide on join (409 LobbyConflict). Namespacing the auth cache by a hash of this install's // path gives each clone a stable, distinct identity. Harmless in production - separate machines // already have separate caches; this only matters for same-machine clone testing. var options = new InitializationOptions(); options.SetProfile(ProfileForThisInstall()); await Timed(UnityServices.InitializeAsync(options)); } if (!AuthenticationService.Instance.IsSignedIn) await Timed(AuthenticationService.Instance.SignInAnonymouslyAsync()); } // Deterministic, regex-safe (^[a-zA-Z0-9_-]{1,30}$) profile derived from the install path, so the same // clone is always the same UGS player across restarts - no random per-launch identity, no orphaned ghost // players/lobbies piling up. SHA1 over dataPath -> "mirror_" + 12 hex chars = 19 chars (fits the limit). private static string ProfileForThisInstall() { using (var sha1 = System.Security.Cryptography.SHA1.Create()) { var bytes = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Application.dataPath)); var name = new System.Text.StringBuilder("mirror_", 19); for (int i = 0; i < 6; i++) name.Append(bytes[i].ToString("x2")); return name.ToString(); } } // Shared busy/sign-in/error skeleton around one session operation; the body carries the // op-specific statuses and state. ownsConnection = the op starts NGO (host/join), so a timeout that // left the transport half-up must tear it down. private async Task RunSessionOpAsync(string failLabel, bool ownsConnection, Func body) { SetBusy(true); try { SetStatus("Signing in…"); await EnsureSignedInAsync(); await body(); } catch (TimeoutException) { // A create/join can bring the shared transport up before it hangs; drop that partial host so we // don't leave the user "connected" with no session. Only for host/join - a refresh may legitimately // run while already hosting, and must not shut that host down. if (ownsConnection && IsConnected) _net.Shutdown(); SetStatus($"{failLabel} timed out after {connectTimeoutSeconds:0}s"); Debug.LogWarning($"[OnlineSession] {failLabel} timed out after {connectTimeoutSeconds:0}s"); } catch (Exception e) { SetStatus($"{failLabel} failed: {e.Message}"); Debug.LogException(e); } finally { SetBusy(false); } } // The UGS session calls take no CancellationToken, so a hung host/join/sign-in would otherwise block // forever - _busy never clears and the browser UI locks permanently. WhenAny bounds each call. The // abandoned op keeps running (hot task, no cooperative cancel), so disposeLate tears down any session // that materializes AFTER we reported the timeout - preventing an orphaned Relay allocation / ghost host. private async Task Timed(Task op, Func disposeLate = null) { if (connectTimeoutSeconds <= 0f) return await op; using (var timer = new CancellationTokenSource()) { if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op) { timer.Cancel(); return await op; } } DiscardLate(op, disposeLate); throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s"); } private async Task Timed(Task op) { if (connectTimeoutSeconds <= 0f) { await op; return; } using (var timer = new CancellationTokenSource()) { if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op) { timer.Cancel(); await op; return; } } Observe(op); throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s"); } private static async void DiscardLate(Task op, Func disposeLate) { T result; try { result = await op; } catch { return; } // op faulted late: nothing was created if (disposeLate == null) return; try { await disposeLate(result); } catch (Exception e) { Debug.LogWarning($"[OnlineSession] late-timeout cleanup failed: {e.Message}"); } } // Observe an abandoned task so its result/exception is never left unhandled. private static async void Observe(Task op) { try { await op; } catch { } } // Late-cleanup for a timed-out create/join that yields a session anyway. We share ONE NetworkManager, so // leaving `late` also tears down the live transport - only reclaim the ghost when the user hasn't since // established a tracked session (a successful retry sets _session). Runs on the main thread: no race. private Task LeaveIfDisplaced(ISession late) => (late != null && _session == null) ? late.LeaveAsync() : Task.CompletedTask; private async void HostAsync() { if (_busy || IsConnected) return; await RunSessionOpAsync("Host", true, async () => { SetStatus("Creating session…"); var options = new SessionOptions { Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(), MaxPlayers = Mathf.Max(2, maxPlayers) }.WithRelayNetwork(); _session = await Timed(MultiplayerService.Instance.CreateSessionAsync(options), LeaveIfDisplaced); SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})"); }); } private async void RefreshAsync() { if (_busy) return; await RunSessionOpAsync("Refresh", false, async () => { SetStatus("Querying sessions…"); var results = await Timed(MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions())); _sessions = new List(results.Sessions); SessionsChanged?.Invoke(); SetStatus($"{_sessions.Count} session(s) found"); }); } private async void JoinAsync(string sessionId) { if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return; await RunSessionOpAsync("Join", true, async () => { SetStatus("Joining session…"); _session = await Timed(MultiplayerService.Instance.JoinSessionByIdAsync(sessionId), LeaveIfDisplaced); SetStatus($"Joined \"{_session.Name}\""); }); } private async void DisconnectAsync() { if (_busy) return; // A UGS session leaves cleanly (LeaveAsync also tears down the NGO connection). A plain loopback // host/client - if one is ever active without a session - just shuts the transport down. if (_session != null) { SetBusy(true); _leaving = true; var session = _session; _session = null; try { SetStatus("Leaving…"); await session.LeaveAsync(); SetStatus("Left session"); } catch (Exception e) { SetStatus($"Leave failed: {e.Message}"); Debug.LogException(e); } finally { _leaving = false; SetBusy(false); } } else if (IsConnected) { _net.Shutdown(); SetStatus("Disconnected"); } } }