From a59a5e53ab3692636504c3097dc032d36921c367 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 2 Jul 2026 20:09:19 +0200 Subject: [PATCH] Add persistent per-install player identity Introduce PersistentPlayerId: a stable id that survives an app restart and is unique per install (built on the UGS install-path-namespaced anonymous PlayerId, with a local PlayerPrefs GUID fallback for loopback). Clones on the same machine stay distinct. Each client reports its id to the server on connect via a new Match_Identity named message (mirroring Match_ReportHit, so no ConnectionApproval timing risk); the host records its own directly. The server keeps an ephemeral clientId -> persistent id map, exposed via PersistentIdOf(clientId) as the foundation for later reconnect recognition. Map entries are cleared on disconnect and net stop. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/Network/MatchManager.cs | 63 +++++++++++++++++++ Assets/Scripts/Network/PersistentPlayerId.cs | 58 +++++++++++++++++ .../Network/PersistentPlayerId.cs.meta | 2 + 3 files changed, 123 insertions(+) create mode 100644 Assets/Scripts/Network/PersistentPlayerId.cs create mode 100644 Assets/Scripts/Network/PersistentPlayerId.cs.meta diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index c71cfd3..921d386 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -183,11 +183,18 @@ public class MatchManager : MonoBehaviour // reports a hit on a hider (ulong target client id); the server validates and marks the hider found. private const string MsgPhase = "Match_Phase"; private const string MsgReportHit = "Match_ReportHit"; + // Client -> server, right after the client connects: the sender's persistent per-install id (survives its + // app restarts). Lets the server map the ephemeral NGO clientId to a stable identity - the reconnect + // foundation. See PersistentPlayerId. + private const string MsgIdentity = "Match_Identity"; // Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it // empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended // early via the authority's Back-to-Lobby). private readonly HashSet _pendingHiders = new HashSet(); + // Server-only. Live NGO clientId -> persistent per-install id, reported by each client on connect. The + // clientId is ephemeral (reused after a disconnect); the value is stable across that client's restarts. + private readonly Dictionary _persistentIds = new Dictionary(); private Coroutine _phaseRoutine; private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast @@ -237,6 +244,7 @@ public class MatchManager : MonoBehaviour private void UnhookSessionCallbacks() { _net.OnClientConnectedCallback -= OnClientConnected; + _net.OnClientConnectedCallback -= OnLocalClientConnected; _net.OnClientDisconnectCallback -= OnClientDisconnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; if (_net.CustomMessagingManager != null) @@ -245,6 +253,7 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgIdentity); } } @@ -269,10 +278,12 @@ public class MatchManager : MonoBehaviour { _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage); + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgIdentity, OnIdentityMessage); } _subPhase = MatchSubPhase.None; _pendingHiders.Clear(); + _persistentIds.Clear(); // Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host. _net.OnClientConnectedCallback -= OnClientConnected; @@ -295,6 +306,11 @@ public class MatchManager : MonoBehaviour _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage); _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage); } + + // Report our persistent id to the server the moment our own connection completes (host records its own + // directly). Re-hook defensively in case of re-connect. + _net.OnClientConnectedCallback -= OnLocalClientConnected; + _net.OnClientConnectedCallback += OnLocalClientConnected; } // Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the @@ -314,6 +330,7 @@ public class MatchManager : MonoBehaviour Phase = MatchPhase.Lobby; ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); + _persistentIds.Clear(); if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); @@ -384,6 +401,8 @@ public class MatchManager : MonoBehaviour // A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger. _pendingHiders.Remove(clientId); + // Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner). + _persistentIds.Remove(clientId); if (!_hasAuthorityClient || clientId != _authorityClientId) return; if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it @@ -400,6 +419,50 @@ public class MatchManager : MonoBehaviour } } + // Fired locally when OUR own client connection completes (host and pure client alike). The host is already + // the server, so it records its own identity directly; a pure client reports it to the server by message. + private void OnLocalClientConnected(ulong clientId) + { + if (_net == null || clientId != _net.LocalClientId) return; + + if (_net.IsServer) RegisterIdentity(_net.LocalClientId, PersistentPlayerId.Value); + else SendIdentityToServer(PersistentPlayerId.Value); + } + + // Client -> server: report this install's persistent id so the server can map it to our ephemeral clientId. + private void SendIdentityToServer(string persistentId) + { + var cmm = _net != null ? _net.CustomMessagingManager : null; + if (cmm == null || string.IsNullOrEmpty(persistentId)) return; + // Grows up to the cap if a future id source is longer than the initial buffer (persistent ids are short). + using (var writer = new FastBufferWriter(256, Allocator.Temp, 1024)) + { + writer.WriteValueSafe(persistentId); + cmm.SendNamedMessage(MsgIdentity, NetworkManager.ServerClientId, writer); + } + } + + // Server handler: a client reported its persistent per-install id right after connecting. + private void OnIdentityMessage(ulong senderClientId, FastBufferReader reader) + { + reader.ReadValueSafe(out string persistentId); + if (_net == null || !_net.IsServer) return; + RegisterIdentity(senderClientId, persistentId); + } + + // Server-only. Records the ephemeral clientId -> persistent id mapping (idempotent; ignores empty ids). + private void RegisterIdentity(ulong clientId, string persistentId) + { + if (string.IsNullOrEmpty(persistentId)) return; + _persistentIds[clientId] = persistentId; + Debug.Log($"[MatchManager] Client {clientId} persistent id: {persistentId}", this); + } + + /// Server-only. The stable per-install id a client reported on connect (survives that client's + /// app restarts), or null if unknown / not yet received. Foundation for reconnect recognition. + public string PersistentIdOf(ulong clientId) => + _persistentIds.TryGetValue(clientId, out var id) ? id : null; + // Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op // if the client already owns a player object (double-spawn guard) or has since disconnected. private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at) diff --git a/Assets/Scripts/Network/PersistentPlayerId.cs b/Assets/Scripts/Network/PersistentPlayerId.cs new file mode 100644 index 0000000..ec3e591 --- /dev/null +++ b/Assets/Scripts/Network/PersistentPlayerId.cs @@ -0,0 +1,58 @@ +using System; +using Unity.Services.Authentication; +using UnityEngine; + +/// +/// A stable, per-install unique player id that survives application restarts, so the server can recognise a +/// returning client across sessions (the NGO clientId is ephemeral and reused). +/// +/// Primary source is the UGS anonymous PlayerId, which is already cached +/// per machine AND namespaced per install via the auth profile (see OnlineSessionManager.ProfileForThisInstall), +/// so it is restart-stable and stays distinct even for same-machine clone tests. When there was no UGS sign-in +/// (pure loopback / offline play) it falls back to a locally generated GUID persisted in PlayerPrefs. +/// +public static class PersistentPlayerId +{ + private const string PrefKey = "chameleon.persistentPlayerId"; + + private static string _ugsId; // authoritative once signed in; cached permanently + private static string _localFallback; // loopback/offline only + + /// The persistent id for THIS install. Stable across restarts, never null/empty. Prefers the + /// signed-in UGS id (the send path runs after sign-in, so online clients always report that); only pure + /// loopback play resolves the local fallback. + public static string Value + { + get + { + if (!string.IsNullOrEmpty(_ugsId)) return _ugsId; + + // AuthenticationService.Instance throws if UGS was never initialised (loopback) - guard it. + try + { + var auth = AuthenticationService.Instance; + if (auth != null && auth.IsSignedIn && !string.IsNullOrEmpty(auth.PlayerId)) + return _ugsId = auth.PlayerId; + } + catch (Exception) { /* services not initialised - use the local fallback */ } + + return LocalFallback(); + } + } + + // NOT cached into _ugsId: a later signed-in read must be able to upgrade to the authoritative UGS id. + // The fallback itself is restart-stable (PlayerPrefs), which is all offline play needs. + private static string LocalFallback() + { + if (!string.IsNullOrEmpty(_localFallback)) return _localFallback; + + _localFallback = PlayerPrefs.GetString(PrefKey, ""); + if (string.IsNullOrEmpty(_localFallback)) + { + _localFallback = Guid.NewGuid().ToString("N"); + PlayerPrefs.SetString(PrefKey, _localFallback); + PlayerPrefs.Save(); + } + return _localFallback; + } +} diff --git a/Assets/Scripts/Network/PersistentPlayerId.cs.meta b/Assets/Scripts/Network/PersistentPlayerId.cs.meta new file mode 100644 index 0000000..2a469ca --- /dev/null +++ b/Assets/Scripts/Network/PersistentPlayerId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff7e4aa09ded2724e9f12b92cd6c6f95 \ No newline at end of file