using Microsoft.AspNetCore.Mvc; using System.Text.Json.Serialization; using DorkNet.Server.Auth; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Match; /// /// match.rec.net/player — bulk player-presence query used by /// RecNet.Matchmaking.RefreshCachedPlayerPresences. /// /// URL pattern verified against /// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Matchmaking.txt /// lines 3565-3568: /// String.Join("&", ids) → String.Concat("player?", joined). /// So the request URL is literally player?{id1}&{id2}&{id3}... /// — every query-string KEY is one account id, with no value. The previous /// [FromQuery(Name="id")] int[] ids binding only matched /// ?id=X&id=Y and never picked up any of the watch's bare-id /// requests, which is why every other player rendered as offline: with no /// ids parsed, the response was [] and IsOnline (which checks /// roomInstance != null) defaulted to false. /// /// Response shape: List<PlayerPresence> — keys verified by /// disassembling RecNet.PlayerPresence.Deserialize: /// "playerId" (int, required) /// "statusVisibility" (int, required) /// "deviceClass" (int, required) /// "vrMovementMode" (only read when deviceClass == 1) /// "roomInstance" (object, optional) /// /// PlayerPresence.IsOnline returns true iff /// roomInstance is non-null — so we surface the live RoomInstance /// from rather than always sending /// null. Players who haven't /goto'd anywhere still report offline. /// [ApiController] public class PlayerPresenceController( PlayerPresenceService presence) : ControllerBase { [HttpGet("/player")] public ActionResult> GetPresences() { // The watch sends two URL flavours for this endpoint: // 1. Bulk: /player?{id1}&{id2}&{id3} — bare keys, // from RefreshCachedPlayerPresences (Matchmaking.txt:3565). // 2. Single: /player?id={N} — observed in the live trace // from a different code path (probably the per-player presence // probe). Both must work; an empty response makes // PUNNetworkManager skip Photon region setup, which then // crashes PingPhotonRegionsInternal with // "An item with the same key has already been added. Key: none" // and leaves the player on a black screen post-dorm-load. // // We accept ANY query parameter whose value parses as a long, plus // any bare key that does. Same dedup → same DB lookup → same // response shape regardless. var ids = new HashSet(); foreach (var (key, values) in Request.Query) { if (long.TryParse(key, out var fromKey) && fromKey > 0) ids.Add(fromKey); foreach (var v in values) { if (long.TryParse(v, out var fromValue) && fromValue > 0) ids.Add(fromValue); } } // Caller's own id gets the FULL RoomInstance (photonRoomId + // dataBlob) so the watch's cached PlayerPresence for itself // matches what /player/heartbeat returns. The watch calls // /player/?id=&id=&... at boot via // RefreshCachedPlayerPresences and then caches each entry; if // self's entry is redacted (photonRoomId="" / dataBlob="") but // heartbeat later returns the full values, RoomInstance. // RoomInstancesAreDifferent fires → "presence heartbeat // response indicates local presence is out-of-sync!" → // OnPresenceHeartbeatResponse tears the player out of the // current room and re-joins, killing any in-flight save flow // mid-coroutine. Only redact for OTHER players where the // privacy concern actually applies. var callerId = this.CurrentPlayerId(); var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null; var result = ids.Select(id => { var room = presence.GetRoom(id); // Other players need both a room presence and an active // live signal. Use the recent-heartbeat marker as the // truth source: SignalR can drop while the player is still // heartbeating, or linger after the game is gone. Presence // keys intentionally live for minutes to survive long room // loads, so using them alone leaves friends stuck online // after a graceful disconnect. var isSelf = callerId == id; var active = isSelf || presence.IsRecentlyActive(id); var isOnline = room is not null && active; return new PlayerPresenceDto { PlayerId = (int)id, // StatusVisibility is the player's *preference* for who may // see them online. The enum is Everyone=0 / FriendsOnly=1 / // Nobody=2 — so the previous value 1 (mislabeled "Visible") // actually meant FriendsOnly, and anyone who wasn't your // friend saw you as OFFLINE. On a private server everyone // should see everyone, so report Everyone (0). IsOnline=false // still gates the "Online" badge for offline players. StatusVisibility = 0, // Everyone DeviceClass = 0, // Desktop / unknown — not VR VrMovementMode = 0, // Full instance details are safe, and necessary, when the // queried player is already co-present with the caller. // PUN asks /player?{remotePlayerId} after a Photon actor // joins; returning a redacted same-room instance there // leaves the 2020 client with an incomplete roomInstance // and can null-ref while reconciling player presence. RoomInstance = isOnline && ShouldExposeFullRoom(callerId, callerRoom, id, room) ? room : RoomInstanceDto.Redact(isOnline ? room : null), IsOnline = isOnline, }; }).ToList(); return Ok(result); } private static bool ShouldExposeFullRoom( long? callerId, RoomInstanceDto? callerRoom, long targetId, RoomInstanceDto? targetRoom) { if (targetRoom is null) return false; if (targetId == callerId) return true; if (callerRoom is null) return false; if (callerRoom.RoomInstanceId != 0 && callerRoom.RoomInstanceId == targetRoom.RoomInstanceId) { return true; } return !string.IsNullOrEmpty(callerRoom.PhotonRoomId) && string.Equals(callerRoom.PhotonRoomId, targetRoom.PhotonRoomId, StringComparison.Ordinal); } } public class PlayerPresenceDto { [JsonPropertyName("playerId")] public int PlayerId { get; set; } /// PlayerStatusVisibility enum (int): /// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly. [JsonPropertyName("statusVisibility")] public int StatusVisibility { get; set; } /// RecNet.DeviceClass enum (int). [JsonPropertyName("deviceClass")] public int DeviceClass { get; set; } /// VRMovementMode enum (int) — only consulted when /// deviceClass == 1. [JsonPropertyName("vrMovementMode")] public int VrMovementMode { get; set; } /// Read by Util.GetObjectKey<RoomInstance> — must /// be a nested object (or null), NOT a string. Empty string "" /// triggers /// InvalidCastException: Unable to cast object of type 'String' /// to type 'Dictionary'2'. null means "player not in /// any room" → IsOnline = false. [JsonPropertyName("roomInstance")] public object? RoomInstance { get; set; } = null; // 2020.12 PlayerPresence.Deserialize (EFAJBHGDIDD.PPGFHEDFBEA) reads // these as required keys after roomInstance. Missing → KeyNotFoundException // → "RecNet presence heartbeat failed: Malformed Response" spam. [JsonPropertyName("isOnline")] public bool IsOnline { get; set; } = true; [JsonPropertyName("appVersion")] // 2023.03.21 client build version ("20230317"); must match the joining // client's own version or it reports a version mismatch. Stale 2020 value // (20201210) carried over from the December branch. public string AppVersion { get; set; } = "20230317"; }