using Microsoft.AspNetCore.Mvc; using Campus_live_rewrite.api.dbs; using Newtonsoft.Json; using Campus_live_rewrite.api.server.Classes.db_class; using static Campus_live_rewrite.api.dbs.HeartbeatDB; using Campus_live_rewrite.api.server; using System.Text; using System.IdentityModel.Tokens.Jwt; using Campus_live_rewrite.api; using static System.Runtime.InteropServices.JavaScript.JSType; using static Campus_live_rewrite.api.server.Classes.RRPlus; using Discord.Net; using System.Net.Http.Headers; namespace Campus_live_rewrite.Controllers { [Route("Matchmaking")] public class MatchmakingController : ControllerBase { public RoomInstance DefaultSessionDorm = new RoomInstance { isFull = false, isInProgress = false, isPrivate = true, location = "76d98498-60a1-430c-ab76-b54a29b7a163", maxCapacity = 4, name = "^" + "DormRoom", roomId = 1, roomInstanceId = 1, roomInstanceType = 0, voiceAuthId = null, voiceServerId = null, subRoomId = 1, matchmakingPolicy = 0, }; private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastOnlineNotice = new(); private static bool FriendOnlineNoticeDue(ulong accountId) { var now = DateTime.UtcNow; if (_lastOnlineNotice.TryGetValue(accountId, out var last) && (now - last).TotalMinutes < ConfigCampus.FriendOnlineNoticeCooldownMinutes) return false; _lastOnlineNotice[accountId] = now; return true; } // The most-populated clubhouse rooms. BARE ARRAY of {RoomId, ClubId, PlayerCount} - a {} // raises the client's Malformed Response throw (wire-shapes: clubhousesearch-mostactivenow). // ClubId is a plain long: send 0, never null. Was a 404. [HttpGet("clubhousesearch/mostactivenow")] public IActionResult MostActiveClubhousesNow() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var rows = HeartbeatDB.LivePlayerCountsByRoom() .Where(kv => kv.Value > 0) .OrderByDescending(kv => kv.Value) .Take(20) .Select(kv => new { RoomId = kv.Key, ClubId = 0L, PlayerCount = kv.Value }) .ToList(); return Ok(rows); } [HttpPost("player/login")] [HttpPost("player/exclusivelogin")] public async Task PlayerLogin() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Daily login streak: credited once per day, before the friend-notice cooldown below can return early. LoginStreaks.OnLogin((ulong)account.Id); // The client posts this on every (re)connect - both /login and /exclusivelogin, and // again after a Photon reconnect - so friends used to get "X is online" two or three // times per session. One notice per player per ConfigCampus.FriendOnlineNoticeCooldownMinutes. if (!FriendOnlineNoticeDue(account.Id)) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 200 }; } var relationships = FriendsDB.GetRelationships(account.Id); List mutualFriendIds = relationships.Where(r => r.RelationshipType == RelationshipType.Friend).Select(r => r.PlayerID).Distinct().ToList(); var message = new MessageData { FromPlayerId = (ulong)account.Id, Id = (ulong)new Random().Next(), SentTime = DateTime.UtcNow, Type = WebsocketEvents.MessageType.FriendStatusOnline, // Data is parsed as a JSON OBJECT by the client's notification decoder: the bare // word "Online" logged "expected:'{', actual:'O', at offset:0" on every friend // login and the frame was dropped. Data = "{}", RoomId = 1 }; var toSkip = new List(); foreach (var friendId in mutualFriendIds) { if (SettingsDB.GetPlayerSettingValue("ReceiveFriendOnlineStatus", friendId) == "False" || DoNotDisturb.Active(friendId)) { toSkip.Add(friendId); } } // Was: mutualFriendIds.Remove(friendId) INSIDE the foreach over // mutualFriendIds - modifying a list while enumerating it throws // InvalidOperationException ("Collection was modified"). It only fired // when a friend actually had the setting off, so it lurked. Collect // first, remove after. mutualFriendIds = mutualFriendIds.Where(id => !toSkip.Contains(id)).ToList(); var ws_message = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(message)); await Notifications.SendToPlayers(mutualFriendIds, ws_message); return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 200 }; } [Route("player/logout")] public ContentResult PlayerLogout() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // The account-creation bootstrap fires a spurious logout right after a new player is // seeded into Orientation (instance -2); clearing it would bounce them to the dorm and // skip the identity flow. A logout that still points at Orientation is a no-op ack // (reference: match /player/logout). if (Campus_live_rewrite.api.server.NewPlayerBootstrap.IsInOrientation(account.Id)) { Console.WriteLine($"[logout] {account.Id}: in Orientation - presence kept"); return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 200 }; } // Quitting the game leaves the party for real - the party thread is otherwise left // holding a player who is not online, which is what made the member count read 2 when // only one person was there. try { foreach (var partyId in Campus_live_rewrite.api.dbs.ChatDB.PartyThreadIdsFor(account.Id)) { Campus_live_rewrite.api.dbs.ChatDB.SystemNotice(partyId, $"Player <@U{account.Id}> left"); Campus_live_rewrite.api.dbs.ChatDB.RemoveMember(partyId, account.Id); Console.WriteLine($"[party] {account.Id} logged out - removed from party thread {partyId}"); } } catch (Exception ex) { Console.WriteLine($"[party] logout leave failed: {ex.Message}"); } HeartbeatDB.UpdatePlayerHeartbeat(account.Id, null, online: false); Webhook.Send(account.Id, "prod", "Matchmaking info", $"({account.Id}) @{account.Username} {account.DisplayName} is logging out of **Campus**", 5898927 ); return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 200 }; } // The client PUTs its VR movement mode (teleport / walk) whenever the player changes it; // it was a 404. Stored on the heartbeat so the presence others see carries it. [HttpPut("player/vrmovementmode")] public IActionResult VrMovementMode() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; int mode = 0; try { if (Request.HasFormContentType) { var v = Request.Form["vrMovementMode"].ToString(); if (string.IsNullOrEmpty(v)) v = Request.Form["VrMovementMode"].ToString(); int.TryParse(v, out mode); } } catch { } HeartbeatDB.UpdateVrMovementMode(account.Id, mode); return Ok(""); } [HttpPut("player/statusvisibility")] [HttpPost("player/statusvisibility")] public IActionResult StatusVisibility() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Form (the client's), query or JSON; a number or the enum name // (Public 0 / FriendsOnly 1 / FavoriteFriendsOnly 2 / Offline 3). var f = RoomModeration.Fields(Request); string raw = RoomModeration.Str(f, "statusVisibility", "StatusVisibility", "visibility", "value") ?? (f.Count == 1 ? f.Values.First() : ""); int statusVisibility; if (!int.TryParse(raw, out statusVisibility)) statusVisibility = raw.ToLowerInvariant() switch { "public" or "everyone" or "all" => 0, "friendsonly" or "friends" => 1, "favoritefriendsonly" or "favorites" or "favourites" => 2, "offline" or "noone" or "nobody" => 3, _ => -1 }; if (statusVisibility < 0 || statusVisibility > 3) { Console.WriteLine($"[presence] {account.Id} statusvisibility: could not read '{raw}' from {RoomModeration.Dump(f)}"); return BadRequest(new { success = false, error = "statusVisibility must be 0-3" }); } HeartbeatDB.UpdateStatusVisibility(account.Id, statusVisibility); // Realtime: "Appear Online To" changed - re-push presence so friends who should now see // (or stop seeing) this player update immediately, not on their next poll - and the // player's OWN record to them, so the picker/status dot flips at once. Notifications.FireAndForget(Notifications.PushPresenceToFriends(account.Id), $"presence-visibility {account.Id}"); // Answer the player's own presence record (bare, the GET player?id= shape) - the // caller that discards it loses nothing; one that reads it is current immediately. var self = HeartbeatDB.GetPresenceForList(account.Id, viewerIsSelf: true, viewerIsFriend: true, viewerIsFavorite: true); return Content(JsonConvert.SerializeObject(self), "application/json"); } // The presence heartbeat. THE CLIENT OBEYS WHATEVER WE ANSWER HERE. // // The request body is EMPTY - verified live, the client sends nothing. What looks // like a client report ("playerId", "roomInstance", "appVersion"...) is OUR OWN // response being read back: it matches HeartbeatDB.Heartbeat field for field and // omits exactly photonRoomIdYeee, the one field we mark [JsonIgnore]. // // So there is nothing to reconcile against. The client compares our answer to // where it actually is, and on any disagreement logs "local presence is // out-of-sync!" and WALKS ITSELF to whatever we said. Answering "nowhere" reads to // it as "you got disconnected", and it goes home to its dorm. // // Everything that matters therefore happens on the read side: see // HeartbeatDB.GetPlayerHeartbeat, which refuses to answer "nowhere" unless // something deliberately put the player there. [HttpPost("player/heartbeat")] public IActionResult PlayerHeartbeat() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // The client DOES send a body here - 78 bytes of application/x-www-form-urlencoded. // // An earlier diagnostic reported it as empty and that was wrong: it read // Request.Form first (which consumes the stream) and then re-read Request.Body, // which by then had nothing left. Read the FORM, not the raw stream. // // Logged once per player so the field names are known without guessing at them. if (Request.HasFormContentType && _heartbeatFormLogged.Add(account.Id)) { try { Console.WriteLine($"[heartbeat] {account.Id} body: " + string.Join("&", Request.Form.Select(kv => $"{kv.Key}={kv.Value}"))); } catch (Exception ex) { Console.WriteLine($"[heartbeat] {account.Id} could not read form: {ex.Message}"); } } // Limited-time events: time in a public room earns event XP (ProgressionEvents). Campus_live_rewrite.api.server.ProgressionEvents.OnHeartbeat(account.Id); var hb = HeartbeatDB.GetPlayerHeartbeat(account.Id); // Lets the avatar-bundle diagnostics name the player/platform behind a download IP. Campus_live_rewrite.api.server.CdnDiag.NoteClient(HttpContext, account, hb.platform, hb.deviceClass); return Content(System.Text.Json.JsonSerializer.Serialize(hb), "application/json"); } // Players whose heartbeat body has already been logged - once each, since // heartbeats fire every ~20s per player. private static readonly System.Collections.Concurrent.ConcurrentDictionary _heartbeatFormSeen = new(); private static class _heartbeatFormLogged { public static bool Add(ulong id) => _heartbeatFormSeen.TryAdd(id, 0); } [HttpPost("matchmake/none")] public ContentResult Matchmaking_none() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Banned / timed out: do not seed a dorm session for them either. This is the // login-lock heartbeat, and seeding here would hand a banned player a fresh // room instance to connect to. if (PlayerDB.HasActiveModerationBlock(account.Id)) { Console.WriteLine($"[matchmake/none] REFUSED {account.Id}: account is banned or timed out"); return new ContentResult() { Content = JsonConvert.SerializeObject(new { roomInstance = (object?)null }), ContentType = "application/json", StatusCode = 200 }; } // "none" = matchmake with no target. The client posts it when it needs an instance but // isn't going anywhere in particular - at startup, and during its own reconnect / // recovery sequences. Reference (recflare match /matchmake/none): "It answers the // instance the player is ALREADY in, so it never warps anyone out of the room they're // standing in; only a player with no live presence falls back to their dorm." // // This used to CLEAR the stored instance instead. Combined with the client posting it // mid-recovery (after a Photon drop, e.g. when the master client leaves), the next // heartbeat answered "nowhere" and the client booted itself to the dorm - the second // half of "one player leaves and the others get kicked" (the first half was // notifydisconnect, above). Presence is only ever cleared by player/logout now. var presence = HeartbeatDB.GetSelfPresence(account.Id).room; if (presence == null) { try { var dormSession = Sessions.CreateDorm(account.Id, account.Username ?? "player"); if (dormSession != null) { HeartbeatDB.UpdatePlayerHeartbeat(account.Id, dormSession); Console.WriteLine($"[matchmake/none] {account.Id} had no live presence: seeded dorm session photonRoomId={dormSession.photonRoomIdYeee}"); } } catch (Exception ex) { Console.WriteLine($"[matchmake/none] Failed to seed dorm for {account.Id}: {ex.Message}"); } } else { // Re-commit the instance they are in (keeps the row and the placement fresh). HeartbeatDB.UpdatePlayerHeartbeat(account.Id, presence, online: true); } var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id)); return new ContentResult() { Content = CurrentHeartbeat, ContentType = "application/json", StatusCode = 200 }; } [HttpPost("matchmake/dorm")] public IActionResult MatchmakeToDorm([FromForm] string correlationId) { string auth = HttpContext.Request.Headers["Authorization"]; if (string.IsNullOrEmpty(auth)) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 401 }; string playerdata = ClientSecurity.DecodeToken(auth); if (playerdata == null || playerdata.Contains("error_token")) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 401 }; var account = JsonConvert.DeserializeObject(playerdata); string token = auth.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase); // Was: jwt.Payload["platform"] via the indexer, then .ToString() on the // result - KeyNotFoundException if the token carries no platform claim, // and a NullReferenceException if it carries a null one. Neither is // recoverable here, and this runs on every dorm join. Tolerate both. Platforms platform = Platforms.All; try { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); if (jwt.Payload.TryGetValue("platform", out var platformObj) && platformObj != null && int.TryParse(platformObj.ToString(), out int platformVal) && Enum.IsDefined(typeof(Platforms), platformVal)) { platform = (Platforms)platformVal; } } catch (Exception jwtEx) { Console.WriteLine($"[matchmake/dorm] JWT platform parse failed for {account.Id}: {jwtEx.Message}"); } // Banned / timed out players DO get their own dorm (a private instance, alone) - it is // the one place they may be. Refusing it handed the client a null room ("error code 3"). // Every other matchmake still refuses them with MatchmakingErrorCode.Banned. if (PlayerDB.HasActiveModerationBlock(account.Id)) Console.WriteLine($"[matchmake/dorm] {account.Id} is banned or timed out - dorm only"); var doesPlayerDormExist = RoomDB.DoesPlayerDormExist(account.Id); if (doesPlayerDormExist == false) { PlayerDB.CreateDormForPlayerId(account.Id); } try { var session = Sessions.CreateDorm(account.Id, account.Username); if (session == null) { var hb = HeartbeatDB.GetPlayerHeartbeat(account.Id); return Ok(hb); } HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session, true, platform, DeviceClasses.Unknown); RoomDB.IncreaseRoomVisit((ulong)session.roomId); Webhook.Send( account.Id, "prod", "Matchmaking Info", $"({account.Id}) @{account.Username} is going to their Dorm Room", 5898927, "", (ulong)session.roomId ); } catch (Exception ex) { // This catch is why the dorm failure was invisible for so long: it // swallowed the throw, UpdatePlayerHeartbeat never ran, and the method // still returned 200 with whatever stale heartbeat happened to be on // file. Keep swallowing (a 500 here would be worse for the client) but // make it unmistakable and greppable in journalctl. Console.WriteLine($"[matchmake/dorm] FAILED for player {account.Id}: {ex}"); } var finalInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance; if (finalInstance == null) { // The client is being handed no room at all - it will appear to join // nothing and will not start its heartbeat loop. Always worth a line. Console.WriteLine($"[matchmake/dorm] WARNING player {account.Id}: returning a NULL roomInstance"); } return Content(System.Text.Json.JsonSerializer.Serialize(new FullRoomInstance { roomInstance = finalInstance, correlationId = string.IsNullOrEmpty(correlationId) ? "00000000-0000-0000-0000-000000000000" : correlationId }), "application/json"); } // The v2 room-matchmake body as the 2025 client posts it (reference openapi: // MatchmakeRoomV2Request): JSON with real types. Only JoinMode (2 = private instance), // CorrelationId and AdditionalPlayerIds are acted on; AdditionalPlayerIds is the caller's // PARTY - null (not []) when alone - and is what pulls the party into the same instance. public class MatchmakeV2Request { public int? JoinMode { get; set; } public string CorrelationId { get; set; } public List? AdditionalPlayerIds { get; set; } public int? InviteMode { get; set; } public bool? ShouldKeepPlayerWithParty { get; set; } // True when the GAME moves a group on together (end of a Laser Tag round) - see GroupMoves. public bool? AdditionalPlayersAutoFollow { get; set; } public bool? BypassMovementModeRestriction { get; set; } public int? MaxPersistenceVersion { get; set; } public int? Ugc1SubVersion { get; set; } public int? Ugc2SubVersion { get; set; } public string? VoiceServerVersion { get; set; } public string? LoginLock { get; set; } public string? ClientJoinData { get; set; } } // The 2025 client POSTs the follow-a-player / accept-invite / join-event calls WITHOUT a // JSON content-type (often an empty body), so a `[FromBody] MatchmakeV2Request` bind answered // 415 Unsupported Media Type before the handler ran - which the client surfaces as the // "Unable to change rooms" popup. These handlers read the (optional) CorrelationId tolerantly // instead, so ANY content-type or an empty body is accepted. matchmake/v2/room + /dorm keep // [FromBody] because the client does send them a proper JSON body. private static readonly System.Collections.Concurrent.ConcurrentDictionary _v2BodyLogged = new(); private MatchmakeV2Request? ReadV2RequestBody() { try { // Diagnostic, a few times per route: the exact body the client posts (content type + // first 400 chars), so the party field's real spelling can be read off the log. // Always log the party-relevant fields of a join (small, joins are rare): this is // the only place the client's party intent is visible to the server. { Request.EnableBuffering(); using var peek = new StreamReader(Request.Body, leaveOpen: true); var rawPeek = peek.ReadToEndAsync().GetAwaiter().GetResult(); Request.Body.Position = 0; string summary = rawPeek; try { if (rawPeek.TrimStart().StartsWith("{")) { var jo = Newtonsoft.Json.Linq.JObject.Parse(rawPeek); summary = $"JoinMode={jo["JoinMode"]} InviteMode={jo["InviteMode"]} KeepWithParty={jo["ShouldKeepPlayerWithParty"]} " + $"AutoFollow={jo["AdditionalPlayersAutoFollow"]} AdditionalPlayerIds={jo["AdditionalPlayerIds"]?.ToString(Newtonsoft.Json.Formatting.None) ?? "null"}"; } } catch { } if (summary.Length > 300) summary = summary.Substring(0, 300); Console.WriteLine($"[matchmake-body] {Request.Path} {summary}"); } // The 2025 client posts these as FORM-URLENCODED, not JSON: // BypassMovementModeRestriction=..&LoginLock=..&VoiceServerVersion=..&CorrelationId=..&MaxPersistenceVersion=.. // Reading the body as JSON failed, so CorrelationId came back null - and a matchmake // response with a null CorrelationId can't be matched to the client's request, so it // ignored the immediate answer and only moved on the next heartbeat (~10s later, // "error then it joins seconds after"). Read the FORM so CorrelationId is echoed. if (Request.HasFormContentType) { var req = new MatchmakeV2Request(); if (Request.Form.TryGetValue("CorrelationId", out var corr)) req.CorrelationId = corr.ToString(); if (Request.Form.TryGetValue("JoinMode", out var jm) && int.TryParse(jm, out var jmv)) req.JoinMode = jmv; if (Request.Form.TryGetValue("AdditionalPlayersAutoFollow", out var af) && bool.TryParse(af, out var afv)) req.AdditionalPlayersAutoFollow = afv; // The 2023 client posts the party as a REPEATED field (one id each). if (Request.Form.TryGetValue("AdditionalPlayerIds", out var ids)) req.AdditionalPlayerIds = ids.Select(v => long.TryParse(v, out var n) ? n : 0).Where(n => n > 0).Distinct().ToList(); return req; } using var reader = new StreamReader(HttpContext.Request.Body); var raw = reader.ReadToEndAsync().GetAwaiter().GetResult(); if (string.IsNullOrWhiteSpace(raw)) return null; return JsonConvert.DeserializeObject(raw); } catch { return null; } } [HttpPost("matchmake/v2/room/{RoomId}")] [HttpPost("matchmake/room/{RoomId}")] public IActionResult MatchmakeRoom(ulong RoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody() ?? new MatchmakeV2Request(); string? corr = request.CorrelationId; // Banned or timed out: matchmake NOWHERE. // // This used to hand back the player's current heartbeat, so if their room // instance was still set the client happily went right back to it - which is // how a banned player kept reappearing in the room they were banned from. // Answer with a null room instance so there is nothing to return to. if (PlayerDB.HasActiveModerationBlock(account.Id)) { Console.WriteLine($"[matchmake] REFUSED {account.Id}: account is banned or timed out"); return MatchmakeResultV2(MatchmakingErrorCode.Banned, null, corr); } var room = RoomDB.GetRoom(RoomId); if (room == null) { // Was NREing on room.Name below. Return current heartbeat so client doesn't // interpret a null response as auth failure and bounce to login. Console.WriteLine($"[matchmake/v2/room/{RoomId}] Room not found"); return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr); } // ---- private rooms need an invite ---------------------------------- // Roles ARE the invite on this server: Creator / CoOwner / TemporaryCoOwner / // Moderator / Host may enter; anyone else is refused. A room-level ban blocks // entry even to a public room. // // Refusing hands back the player's CURRENT heartbeat, so the client leaves them // where they are rather than treating it as an auth failure and bouncing them // to login. if (room == null || !RoomDB.CanPlayerEnterRoom(room, account.Id)) { string why = room == null ? "room not found" : RoomDB.PlayerIsBannedFrom(room, account.Id) ? "banned from that room" : "not published / no invite"; Console.WriteLine($"[matchmake] REFUSED {account.Id} -> room {RoomId}: {why}"); var refuseCode = room != null && RoomDB.PlayerIsBannedFrom(room, account.Id) ? MatchmakingErrorCode.BannedFromRoom : MatchmakingErrorCode.RoomIsPrivate; return MatchmakeResultV2(refuseCode, null, corr); } string roomName = room.Name ?? ""; ulong roomId = room.RoomId; string roomIdentifier = $"^{roomName}"; string tmp = ""; bool isPrivateRoom = request.JoinMode == 2; bool autoFollow = request.AdditionalPlayersAutoFollow == true; // The instance they are in now. Never handed straight back: the client keys the room // change off a NEW roomInstanceId, so answering "Join new instance" with the instance // they are standing in made it rejoin the same room over and over. long fromInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance?.roomInstanceId ?? 0; // Part of a group the game is moving together (end of a round): where the group went. var grouped = GroupMoves.TakeFor(account.Id, roomId, -1, fromInstance, autoFollow); // Public joins REUSE an instance someone is already in, so players actually // meet. Creating a fresh instance every time (which is what this used to do) // only worked because every public instance shared the Photon room name // "room" - the same bug that put strangers from other rooms in your world. RoomInstance session; if (grouped != null) { session = grouped; } else if (isPrivateRoom) { session = Sessions.CreateRoom(roomName, "", true); } else { // Only pool into someone else's session when sharing is allowed. A // published PRIVATE room is enterable but not shareable, so each visitor // gets their own instance and can bring their own friends. session = RoomDB.CanPlayerShareInstance(room, account.Id) ? HeartbeatDB.FindJoinableInstance(roomId, account.Id, fromInstance)! : null!; if (session != null) Console.WriteLine($"[matchmake] {account.Id} joining existing instance of ^{roomName} " + $"(photon {session.photonRoomIdYeee}, instance {session.roomInstanceId})"); else { session = Sessions.CreateRoom(roomName)!; if (session != null) Console.WriteLine($"[matchmake] {account.Id} opening a NEW instance of ^{roomName} " + $"(photon {session.photonRoomIdYeee}, instance {session.roomInstanceId})"); } } if (isPrivateRoom) { tmp = "[PRIVATE INSTANCE]"; } RoomDB.IncreaseRoomVisit(roomId); PlayerDB.SetRecentRooms(account.Id, roomId); if (session == null) { Console.WriteLine($"[matchmake] {account.Id} -> room {RoomId}: no instance could be made"); return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr); } HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session); if (grouped == null) GroupMoves.Record(account.Id, fromInstance, session, request.AdditionalPlayerIds, autoFollow, -1); var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id)); Webhook.Send( account.Id, "prod", "Matchmaking Info", $"({account.Id}) @{account.Username} is going to ^{roomName} {tmp}", 5898927, "", roomId ); var landed = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance ?? session; InviteParty(account.Id, account.Username, request.AdditionalPlayerIds, landed, fromInstance); return MatchmakeResultV2(MatchmakingErrorCode.Success, landed, corr); } [HttpPost("matchmake/v2/room/{RoomId}/{SubRoomId}")] [HttpPost("matchmake/room/{RoomId}/{SubRoomId}")] public IActionResult MatchmakeRoom(ulong RoomId, ulong SubRoomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody() ?? new MatchmakeV2Request(); string? corr = request.CorrelationId; var roomCheck = RoomDB.GetRoom(RoomId); if (roomCheck == null) { Console.WriteLine($"[matchmake/v2/room/{RoomId}/{SubRoomId}] Room not found"); return MatchmakeResultV2(MatchmakingErrorCode.NoSuchRoom, null, corr); } if (PlayerDB.HasActiveModerationBlock(account.Id)) { var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id); return new ContentResult { Content = JsonConvert.SerializeObject(new { heartbeat.playerId, heartbeat.appVersion, heartbeat.deviceClass, errorCode = MatchmakingErrorCode.Banned, heartbeat.isOnline, heartbeat.lastOnline, heartbeat.platform, roomInstance = (string?)null, heartbeat.statusVisibility, heartbeat.vrMovementMode }), ContentType = "application/json", StatusCode = 200 }; } var room = RoomDB.GetRoom(RoomId); // ---- private rooms need an invite ---------------------------------- // Roles ARE the invite on this server: Creator / CoOwner / TemporaryCoOwner / // Moderator / Host may enter; anyone else is refused. A room-level ban blocks // entry even to a public room. // // Refusing hands back the player's CURRENT heartbeat, so the client leaves them // where they are rather than treating it as an auth failure and bouncing them // to login. if (room == null || !RoomDB.CanPlayerEnterRoom(room, account.Id)) { string why = room == null ? "room not found" : RoomDB.PlayerIsBannedFrom(room, account.Id) ? "banned from that room" : "not published / no invite"; Console.WriteLine($"[matchmake] REFUSED {account.Id} -> room {RoomId}: {why}"); var refuseCode = room != null && RoomDB.PlayerIsBannedFrom(room, account.Id) ? MatchmakingErrorCode.BannedFromRoom : MatchmakingErrorCode.RoomIsPrivate; return MatchmakeResultV2(refuseCode, null, corr); } string roomName = room.Name ?? ""; ulong roomId = room.RoomId; string roomIdentifier = $"^{roomName}"; string subroomname = RoomDB.get_subroom_from_id(RoomId, SubRoomId); string tmp = ""; bool isPrivateRoom = request.JoinMode == 2; bool autoFollow = request.AdditionalPlayersAutoFollow == true; long fromInstance = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance?.roomInstanceId ?? 0; // Part of a group the game is moving together (the end of a Laser Tag round posts // this route from every client): land where the first of them went. var grouped = GroupMoves.TakeFor(account.Id, roomId, (long)SubRoomId, fromInstance, autoFollow); RoomInstance session = grouped ?? (isPrivateRoom ? Sessions.CreateRoom(roomName, subroomname, true) : Sessions.CreateRoom(roomName, subroomname)); if (isPrivateRoom) { tmp = "[PRIVATE INSTANCE]"; } RoomDB.IncreaseRoomVisit(roomId); PlayerDB.SetRecentRooms(account.Id, roomId); if (session == null) { var heartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id)); return new ContentResult { Content = JsonConvert.SerializeObject(heartbeat), ContentType = "application/json", StatusCode = 200 }; } HeartbeatDB.UpdatePlayerHeartbeat(account.Id, session); if (grouped == null) GroupMoves.Record(account.Id, fromInstance, session, request.AdditionalPlayerIds, autoFollow, (long)SubRoomId); var CurrentHeartbeat = JsonConvert.SerializeObject(HeartbeatDB.GetPlayerHeartbeat(account.Id)); Webhook.Send( account.Id, "prod", "Matchmaking Info", $"({account.Id}) @{account.Username} is going to ^{roomName}.{subroomname} {tmp}", 5898927, "", roomId ); var landed = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance ?? session; InviteParty(account.Id, account.Username, request.AdditionalPlayerIds, landed, fromInstance); return MatchmakeResultV2(MatchmakingErrorCode.Success, landed, corr); } /// /// JOIN EVENT. The client POSTs this when the button is pressed; it did not exist, so /// every press 404'd and the client showed "Unable to change rooms". /// /// Entry rules (see EventDB.CanPlayerJoin): /// creator -> any time, so they can set up before it starts /// invited -> any time /// anyone else -> only once it has started, and not after it has ended /// /// A refusal hands back the player's CURRENT heartbeat, the same way the room /// matchmaker refuses: the client leaves them where they are instead of treating it /// as an auth failure. /// [HttpPost("matchmake/event/{EventId}")] public IActionResult MatchmakeEvent(ulong EventId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); Console.WriteLine($"[matchmake/event] ENTER {account.Id} -> event {EventId}"); // /matchmake/event is NOT a v2 path, so it answers the lowercase envelope. A refusal is a // non-Success code with a null instance (NOT the caller's current room - that echoed a // "go where you already are" that read as the "Unable to change rooms" stall). // The decision (which instance, overflow, first-in opens it) is shared with the website's Join button. var outcome = EventJoin.Resolve(EventId, account.Id); if (outcome.Session == null) return MatchmakeResultLower(outcome.Code, null, request?.CorrelationId); // Success in the lowercase envelope with the event's live instance, so the client GoTos now. return MatchmakeResultLower(MatchmakingErrorCode.Success, outcome.Session, request?.CorrelationId); } /// /// Marks a room instance as in progress. Also 404'd before now. The event UI uses it to /// flip an event to "happening now" and to point attendees at the right instance. /// // The room panel's refresh (the circular-arrow button beside PRIVATE MODE). It 404'd - // nothing implemented it - and, like Reset, it is an instance-wide action, so it is held // to the same role floor (ConfigCampus.RoomResetMinRole): a moderator or better in the // room the instance belongs to, or a dev. Anyone else gets a 403 and the client leaves // the instance alone. The reference server does not implement this route either, so the // ack shape is the same bare success the sibling inprogress route answers. [HttpPost("roominstance/{roomInstanceId}/refresh")] [HttpPut("roominstance/{roomInstanceId}/refresh")] public IActionResult RefreshRoomInstance(long roomInstanceId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var inst = HeartbeatDB.FindInstanceById(roomInstanceId); if (inst == null) return StatusCode(404, ""); var room = RoomDB.GetRoom((ulong)inst.roomId); int role = (int)(RoomDB.PlayerRoleOn(room, account.Id) ?? RoomDBClasses.Role_data.None); bool allowed = role >= ConfigCampus.RoomResetMinRole || PlayerDB.CheckDevFlag(account.Id); if (!allowed) { Console.WriteLine($"[roominstance] {account.Id} (role {role}) tried to refresh instance {roomInstanceId} of room {inst.roomId} - refused (needs {ConfigCampus.RoomResetMinRole}+)"); return StatusCode(403, ""); } // A refresh OPENS A NEW INSTANCE of the same room/subroom and moves everyone who was // in the old one into it - the presence heartbeat walks each client over (it adopts // whatever instance the server reports), so the room reloads clean for all of them. // Before this it only re-sent the room DTO and nobody moved. string subName = room?.SubRooms?.FirstOrDefault(sr => (long)sr.SubRoomId == inst.subRoomId)?.Name ?? ""; var fresh = Sessions.CreateRoom(room?.Name ?? inst.name ?? "", subName, inst.isPrivate); if (fresh == null) return StatusCode(500, ""); fresh.eventId = inst.eventId; fresh.clubId = inst.clubId; var movers = Campus_live_rewrite.api.server.RoomPermissions.PlayersInRoom((ulong)inst.roomId) .Where(pid => HeartbeatDB.GetPlayerHeartbeat(pid)?.roomInstance?.roomInstanceId == roomInstanceId).ToList(); foreach (var pid in movers) HeartbeatDB.UpdatePlayerHeartbeat(pid, fresh); // An event whose main (broadcasting) instance this was moves with its players. var movedEvent = EventDB.GetEventForRoomInstance((ulong)roomInstanceId); if (movedEvent != null) { EventDB.SetBroadcastingInstance(movedEvent.PlayerEventId, (ulong)fresh.roomInstanceId, true); Console.WriteLine($"[event] event {movedEvent.PlayerEventId} main instance {roomInstanceId} -> {fresh.roomInstanceId} (refresh)"); } Console.WriteLine($"[roominstance] {account.Id} (role {role}) refreshed instance {roomInstanceId} of room {inst.roomId} -> new instance {fresh.roomInstanceId} ({movers.Count} player(s) moved)"); return Ok(new { success = true, roomInstance = fresh }); } [HttpPut("roominstance/{roomInstanceId}/inprogress")] public IActionResult SetRoomInstanceInProgress(ulong roomInstanceId, [FromQuery] bool inProgress = true) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // The client sends the flag as a form field (inProgress=True|False); the query is a // fallback. It is a property of the INSTANCE (reference: any player in it may flip // it), so it lands on every presence row holding that instance. try { if (Request.HasFormContentType && Request.Form.TryGetValue("inProgress", out var f) && bool.TryParse(f.ToString(), out var fb)) inProgress = fb; } catch { } bool was = HeartbeatDB.IsInstanceInProgress(HeartbeatDB.FindInstanceById((long)roomInstanceId)); HeartbeatDB.UpdateInstanceEverywhere((long)roomInstanceId, i => i.isInProgress = inProgress); HeartbeatDB.SetInstanceInProgress((long)roomInstanceId, inProgress); if (was != inProgress) { var inst = HeartbeatDB.FindInstanceById((long)roomInstanceId); Console.WriteLine($"[matchmake] instance {roomInstanceId} (room {inst?.roomId.ToString() ?? "?"}) inProgress={inProgress} by {account.Id}" + (inProgress ? " - new players go to another instance until the round ends" : "")); } // A round starting/ending in an event's instance (a game played during the event) is // not the event moving: clearing BroadcastingRoomInstanceId here on inProgress=false // took the event's main instance away mid-event - overflow instances then got a // broadcast from an instance the event no longer named (the client's // BroadcastRoomInstanceMismatch) and new attendees were scattered. Only log it. var ev = EventDB.GetEventForRoomInstance(roomInstanceId); if (ev != null) Console.WriteLine($"[event] instance {roomInstanceId} inProgress={inProgress} (event {ev.PlayerEventId}, stays its broadcasting instance)"); return Ok(new { success = true }); } // ---- the owner's Instances tab (reference match: GET /room/{id}/instances) ---------- // Live sessions of a room with who is in each. Creator / co-owner / staff only. [HttpGet("room/{roomId}/instances")] public IActionResult RoomInstances(ulong roomId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); if (!RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); var list = HeartbeatDB.LiveInstancesOfRoom(roomId).Select(t => new { roomInstanceId = t.inst.roomInstanceId, roomId = t.inst.roomId, subRoomId = t.inst.subRoomId, isFull = HeartbeatDB.InstanceIsFull(t.inst), isPrivate = t.inst.isPrivate, isInProgress = t.inst.isInProgress, playerCount = t.players.Count, maxCapacity = HeartbeatDB.EffectiveCapacity(t.inst), createdAt = DateTime.UtcNow.ToString("o"), playerIds = t.players.Select(p => (long)p).ToList(), }).ToList(); return Ok(list); } // Mark a live instance private: public matchmaking stops pooling new players into it, // the people already inside are unaffected. Creator / co-owner only (reference). [HttpPost("roominstance/{roomInstanceId}/markprivate")] [HttpPut("roominstance/{roomInstanceId}/markprivate")] public IActionResult MarkInstancePrivate(ulong roomInstanceId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var inst = HeartbeatDB.FindInstanceById((long)roomInstanceId); if (inst == null) return NotFound(); var room = RoomDB.GetRoom((ulong)inst.roomId); if (room == null || !RoomDB.CanModerateRoom(room, account.Id)) return StatusCode(403, ""); int n = HeartbeatDB.UpdateInstanceEverywhere((long)roomInstanceId, i => i.isPrivate = true); Console.WriteLine($"[matchmake] {account.Id} marked instance {roomInstanceId} of ^{inst.name} private ({n} row(s))"); return Ok(new { success = true, error = "" }); } // Join one specific live instance of your own room - the Instances tab's "join" (reference // match POST /matchmake/instance/{id}). Owner / co-owner / staff; enters even a full or // in-progress instance, and never creates one. [HttpPost("matchmake/instance/{instanceId}")] [HttpPost("matchmake/v2/instance/{instanceId}")] public IActionResult MatchmakeInstance(long instanceId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); string? corr = request?.CorrelationId; if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out"); var inst = HeartbeatDB.FindInstanceById(instanceId); if (inst == null) return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"instance {instanceId} is not live"); var room = RoomDB.GetRoom((ulong)inst.roomId); if (room == null || !RoomDB.CanModerateRoom(room, account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, "not a manager of that room"); HeartbeatDB.UpdatePlayerHeartbeat(account.Id, inst); PlayerDB.SetRecentRooms(account.Id, (ulong)inst.roomId); Console.WriteLine($"[matchmake] {account.Id} (owner) joined instance {instanceId} of ^{inst.name}"); return MatchmakeResultV2(MatchmakingErrorCode.Success, inst, corr); } // Matchmake into a club's clubhouse (reference match POST /matchmake/club/{id}): members // only, into the room the club set as its clubhouse. [HttpPost("matchmake/club/{clubId}")] [HttpPost("matchmake/v2/club/{clubId}")] public IActionResult MatchmakeClub(int clubId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); string? corr = request?.CorrelationId; var club = ClubDB.GetById(clubId); if (club == null || club.ClubhouseRoomId == null || club.ClubhouseRoomId <= 0) return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"club {clubId} unknown or has no clubhouse"); if (ClubDB.MyMembership(clubId, account.Id) <= 0) return StayPut(account.Id, corr, MatchmakingErrorCode.NoSuchRoom, $"not a member of club {clubId}"); return MatchmakeRoom((ulong)club.ClubhouseRoomId.Value); } // Join a player out of a chat / party thread ("join" on a party member's card - the 2025 // client posts matchmake/chatinvite/{threadId}/{playerId}, a live 404). Being on the same // thread is the consent: both must be members, then it is the ordinary follow-a-player join. [HttpPost("matchmake/chatinvite/{threadId}/{playerId}")] [HttpPost("matchmake/v2/chatinvite/{threadId}/{playerId}")] public IActionResult MatchmakeChatInvite(long threadId, ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); string? corr = request?.CorrelationId; if (playerId == account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.AlreadyInTargetInstance, "can't join yourself"); if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out"); if (!ChatDB.IsMember(threadId, account.Id) || !ChatDB.IsMember(threadId, playerId)) return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, $"not both on chat thread {threadId}"); if (FriendsDB.IsBlockedEitherWay(account.Id, playerId)) return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other"); if (PlayerDB.HasActiveModerationBlock(playerId) && !PlayerDB.CheckDevFlag(account.Id) && !PlayerDB.CheckModFlag(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target is banned (dorm only)"); var (online, targetRoom) = HeartbeatDB.PeekPresence(playerId); if (!online || targetRoom == null) targetRoom = HeartbeatDB.RecentInstanceFor(playerId); if (targetRoom == null) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target offline / not in a room"); var room = RoomDB.GetRoom((ulong)targetRoom.roomId); if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room"); return JoinInstance(account.Id, account.Username, corr, targetRoom, playerId); } [HttpGet("rooms/requiring/{requirement}")]// ["RecCenter", "test123"] public ContentResult RoomsRequiring(string requirement) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; requirement = requirement?.ToLowerInvariant(); string jsonResponse = requirement switch { "developer" => "[]", "rrplus" => "[]", _ => "[]" }; return new ContentResult { Content = jsonResponse, ContentType = "application/json", StatusCode = 200 }; } // Join the player you tapped in the friends list, or whose game invite you accepted // (`POST /Matchmaking/matchmake/v2/player/{playerId}`). The 2025 client hits this for BOTH, // and it was NOT implemented (live log: 404), so "join" and invite-accept did nothing. // // Lands the caller in the SAME live instance the target is standing in (same Photon room), // read from the target's presence, so they actually meet. Access: mutual friends may always // follow; a non-friend may only join a PUBLIC instance (a private/dorm instance needs // friendship). Gated on a LIVE socket so we never chase a stale room, plus a room-ban check // since this hands out Photon coordinates without going through the room resolver. On any // refusal it returns the caller's CURRENT instance (leave them where they are) rather than a // null room, so the client doesn't read it as a disconnect and bounce them home. Mirrors the // reference `/matchmake/player/:playerId` + `/matchmake/v2/player/:playerId`. // "Leave the caller where they are" - the safe answer to a failed join. Returning a null // room instead reads to the client as a disconnect and walks them home to their dorm. // The PascalCase RoomInstance the v2 client reads out of a matchmake response to know WHERE // to go. `/matchmake/v2/*` and `/matchmake/invite/*` answer this shape; the lowercase // FullRoomInstance we used before had none of these fields, so the client couldn't read the // target's instance from a join-a-player / accept-invite response and fell back to the slow // heartbeat poll (~30s) - the "Unable to change rooms, then it force-joins later" symptom. // If this instance is one an event is broadcasting to, make sure it carries the event id // (the client reads it to show the "This Event" tab). Cheap lazy stamp on read. private static void StampInstanceEvent(RoomInstance? i) { if (i == null || i.eventId != 0) return; var ev = EventDB.GetEventForRoomInstance((ulong)i.roomInstanceId); if (ev != null) i.eventId = (long)ev.PlayerEventId; } private static object ToV2RoomInstance(RoomInstance i) => new { RoomInstanceId = i.roomInstanceId, RoomId = i.roomId, SubRoomId = i.subRoomId, Location = string.IsNullOrEmpty(i.location) ? "76d98498-60a1-430c-ab76-b54a29b7a163" : i.location, // NEVER null: the client's decoder is strict (reference: eventId 0 / clubId 0 / // roomCode ""). A null here failed the parse of a SUCCESSFUL join, the client showed // "Unable to change room", and the player only moved ~20 s later when the presence // heartbeat dragged them over - the "invites are delayed" symptom. EventId = i.eventId, ClubId = i.clubId, RoomCode = "", Name = i.name, MaxCapacity = i.maxCapacity, IsFull = i.isFull, IsPrivate = i.isPrivate, IsInProgress = i.isInProgress, EncryptVoiceChat = i.EncryptVoiceChat, RoomInstanceType = i.roomInstanceType, MatchmakingPolicy = i.matchmakingPolicy }; // PascalCase matchmake envelope for /matchmake/v2/* and /matchmake/invite/* (what the 2025 // client parses): { ErrorCode, CorrelationId, RoomInstance }. ErrorCode 0 = Success; a // refusal is a non-zero code with a null RoomInstance (the client then leaves the player put // WITHOUT reading it as a disconnect - so we no longer echo their current instance back). private IActionResult MatchmakeResultV2(MatchmakingErrorCode code, RoomInstance? instance, string? corr) { return Content(System.Text.Json.JsonSerializer.Serialize(new { ErrorCode = (int)code, // Always a string: an invite accept posts an EMPTY body (seen live), and a null // echo fails the client's strict decoder. Reference: all-zero GUID when none sent. CorrelationId = string.IsNullOrEmpty(corr) ? "00000000-0000-0000-0000-000000000000" : corr, RoomInstance = instance == null ? null : ToV2RoomInstance(StampAndReturn(instance)) }), "application/json"); } private static RoomInstance StampAndReturn(RoomInstance i) { StampInstanceEvent(i); return i; } // Lowercase matchmake envelope for the non-v2 paths (e.g. /matchmake/event/*): // { errorCode, result, roomInstance, correlationId } with the RAW (lowercase) instance. private IActionResult MatchmakeResultLower(MatchmakingErrorCode code, RoomInstance? instance, string? corr) { StampInstanceEvent(instance); return Content(System.Text.Json.JsonSerializer.Serialize(new { errorCode = (int)code, result = (int)code, roomInstance = instance, // never null: the client's field is a non-nullable Guid (reference: Guid.Empty when none sent) correlationId = string.IsNullOrEmpty(corr) ? "00000000-0000-0000-0000-000000000000" : corr }), "application/json"); } // Pull the caller's PARTY into the instance they just landed in (reference: inviteParty). // Each member gets the same recorded room invite POST /invite sends, pointing at this // instance; their client redeems it through /matchmake/invite/{id} or follows the leader // through /matchmake/v2/player/{leader}. The row is what makes the frame redeemable - a // frame with no row reads as expired the moment it arrives. Leader skipped, offline // members skipped, best-effort per member: it never fails the leader's matchmake. private void InviteParty(ulong leaderId, string? leaderName, List? memberIds, RoomInstance? instance, long fromInstance) { if (memberIds == null || memberIds.Count == 0 || instance == null) return; // Only people actually going with them: on their party, or in the instance they just // left (the game moving a whole lobby on). The list is the client's, and it was pulling // players who had only ended up on a merged party thread (ChatController.AdmitToParty) // into whatever room this player went to next. var party = ChatDB.GetPartyThreadFor(leaderId)?.PlayerIds ?? new List(); foreach (var raw in memberIds.Distinct()) { if (raw <= 0) continue; ulong pid = (ulong)raw; if (pid == leaderId) continue; var (online, theirRoom) = HeartbeatDB.PeekPresence(pid); if (!online) { Console.WriteLine($"[party] {leaderId}: member {pid} offline - not invited"); continue; } bool together = party.Contains(pid) || (fromInstance != 0 && theirRoom?.roomInstanceId == fromInstance); if (!together) { Console.WriteLine($"[party] {leaderId}: {pid} is not on their party or in their room - not pulled"); continue; } try { var invite = RoomInvites.Create(leaderId, pid); string data = JsonConvert.SerializeObject(new { InviteId = invite.RoomInviteId, Name = instance.name ?? "", InviteMode = 22 }); var msg = new MessageData { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), FromPlayerId = leaderId, ToPlayerId = pid, SentTime = DateTime.UtcNow, Type = WebsocketEvents.MessageType.GameInviteV2, Data = data, RoomId = (ulong)instance.roomId, PlayerEventId = null }; var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg)); Notifications.FireAndForget(Notifications.SendToPlayer(pid, frame), $"party invite {leaderId}->{pid}"); Console.WriteLine($"[party] {leaderId} @{leaderName} pulled {pid} into ^{instance.name} (instance {instance.roomInstanceId}, invite {invite.RoomInviteId})"); } catch (Exception ex) { Console.WriteLine($"[party] invite {leaderId}->{pid} failed: {ex.Message}"); } } } private IActionResult StayPut(ulong accountId, string? corr, MatchmakingErrorCode code, string why) { Console.WriteLine($"[matchmake/join] {accountId}: {why} (code {(int)code} {code}); leaving them where they are"); return MatchmakeResultV2(code, null, corr); } private IActionResult JoinInstance(ulong accountId, string? username, string? corr, RoomInstance target, ulong followedPlayerId) { // Kicked out of this instance: not back in through a friend / invite either, until the // block runs out. (Any other instance of the room is fine.) var block = InstanceBlocks.Active(accountId, target); if (block != null) return StayPut(accountId, corr, MatchmakingErrorCode.BlockedFromRoom, $"kicked from instance {target.roomInstanceId} of ^{target.name}, blocked until {block.Until:HH:mm}Z"); // A full instance (the subroom's MaxPlayers - 4 in Jumbotron) refuses the join // instead of stuffing a fifth player in; the client shows "room is full". if (HeartbeatDB.InstanceIsFull(target)) { var mine = HeartbeatDB.PeekPresence(accountId).room; if (mine == null || mine.roomInstanceId != target.roomInstanceId) return StayPut(accountId, corr, MatchmakingErrorCode.InsufficientSpace, $"instance {target.roomInstanceId} of ^{target.name} is full ({HeartbeatDB.CountInInstance(target.roomInstanceId)}/{HeartbeatDB.EffectiveCapacity(target)})"); } HeartbeatDB.UpdatePlayerHeartbeat(accountId, target); PlayerDB.SetRecentRooms(accountId, (ulong)target.roomId); Console.WriteLine($"[matchmake/join] {accountId} @{username} joining {followedPlayerId} in " + $"^{target.name} (instance {target.roomInstanceId}, photon {target.photonRoomIdYeee})"); // Return the TARGET's instance in the PascalCase shape so the client GoTos immediately. return MatchmakeResultV2(MatchmakingErrorCode.Success, target, corr); } [HttpPost("matchmake/v2/player/{playerId}")] [HttpPost("matchmake/player/{playerId}")] public IActionResult MatchmakePlayer(ulong playerId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); Console.WriteLine($"[matchmake/join] ENTER {account.Id} -> player {playerId}"); string? corr = request?.CorrelationId; if (playerId == account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.AlreadyInTargetInstance, "can't join yourself"); if (PlayerDB.HasActiveModerationBlock(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.Banned, "caller banned/timed out"); if (FriendsDB.IsBlockedEitherWay(account.Id, playerId)) return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other"); // Where is the target right now? Live-socket gated (PeekPresence) so a crashed/closed // player's stale room is never chased. // A banned player is signed in but confined to their own dorm, alone - nobody follows // them in (staff excepted). if (PlayerDB.HasActiveModerationBlock(playerId) && !PlayerDB.CheckDevFlag(account.Id) && !PlayerDB.CheckModFlag(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target is banned (dorm only)"); var (online, targetRoom) = HeartbeatDB.PeekPresence(playerId); if (!online || targetRoom == null) { // The target has no live room. If they were in one within the last couple of // minutes, the caller is almost certainly regrouping after the whole room dropped // (master-client switch recovery: both players reset, both rows cleared, each // asks to join the other). Send them to that instance so the group re-forms. var recent = HeartbeatDB.RecentInstanceFor(playerId); if (recent == null) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "target offline / not in a room"); Console.WriteLine($"[matchmake/join] {account.Id} -> player {playerId}: target dropped moments ago, regrouping into their last instance {recent.roomInstanceId}"); targetRoom = recent; } // Who may follow: mutual friends always; anyone holding a live invite FROM this player // (the accept-invite path); otherwise only into a PUBLIC instance. bool friends = FriendsDB.GetRelationships(account.Id) .Any(r => r.PlayerID == playerId && r.RelationshipType == RelationshipType.Friend); bool invited = RoomInvites.HasInviteFromTo(playerId, account.Id); // A party member following the leader: the client never posts the party, so "we were // in the same instance a moment ago" is what stands in for membership on the gate. bool together = HeartbeatDB.WereTogetherRecently(account.Id, playerId); if (!friends && !invited && !together && targetRoom.isPrivate) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInstanceIsPrivate, "not friends/invited/party and target is in a private instance"); // An UNPUBLISHED room is the owner's draft: friendship alone does not get you in. Only a // role on the room or a live invite from someone inside does (a party regroup counts). var targetRoomData = RoomDB.GetRoom((ulong)targetRoom.roomId); if (targetRoomData != null && !RoomDB.IsRoomPublished(targetRoomData) && !RoomDB.PlayerIsMemberOf(targetRoomData, account.Id) && !invited && !together) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInstanceIsPrivate, "target is in an unpublished room and the caller was not invited"); if (together && !friends && !invited) Console.WriteLine($"[matchmake/join] {account.Id} -> {playerId}: allowed as a recent party companion"); var room = RoomDB.GetRoom((ulong)targetRoom.roomId); if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room"); if (invited) RoomInvites.ConsumeFromTo(playerId, account.Id); return JoinInstance(account.Id, account.Username, corr, targetRoom, playerId); } // Redeem a game invite by its id (`POST /Matchmaking/matchmake/invite/{inviteId}` - the // 2025 client's join button on an invite). Follows the INVITER's live presence, so it lands // you wherever they are now, and is single-use. Was 404. [HttpPost("matchmake/invite/{inviteId}")] public IActionResult MatchmakeInvite(long inviteId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var request = ReadV2RequestBody(); Console.WriteLine($"[matchmake/invite] ENTER {account.Id} -> invite {inviteId}"); string? corr = request?.CorrelationId; var inv = RoomInvites.Get(inviteId); if (inv == null) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInviteExpired, $"invite {inviteId} expired/unknown"); if (inv.ToPlayerId != account.Id) return StayPut(account.Id, corr, MatchmakingErrorCode.RoomInviteExpired, "invite not addressed to caller"); if (FriendsDB.IsBlockedEitherWay(account.Id, inv.FromPlayerId)) return StayPut(account.Id, corr, MatchmakingErrorCode.InsufficientRelationship, "one of them has blocked the other"); if (PlayerDB.HasActiveModerationBlock(inv.FromPlayerId) && !PlayerDB.CheckDevFlag(account.Id) && !PlayerDB.CheckModFlag(account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "inviter is banned (dorm only)"); var (online, targetRoom) = HeartbeatDB.PeekPresence(inv.FromPlayerId); if (!online || targetRoom == null) return StayPut(account.Id, corr, MatchmakingErrorCode.PlayerNotOnline, "inviter offline / not in a room"); var room = RoomDB.GetRoom((ulong)targetRoom.roomId); if (room != null && RoomDB.PlayerIsBannedFrom(room, account.Id)) return StayPut(account.Id, corr, MatchmakingErrorCode.BannedFromRoom, "banned from that room"); RoomInvites.Consume(inviteId); return JoinInstance(account.Id, account.Username, corr, targetRoom, inv.FromPlayerId); } // Send a game invite (`POST /Matchmaking/invite`) - was 404, which is why the friends-list // "Invite" button showed "Could not invite / Unable to send game invite". Records an invite // and pushes a GameInviteV2 MessageReceived frame to the target so their client shows the // join prompt. Mirrors RecFlare's POST /invite. Per the owner's rule, an OFFLINE (or // appear-offline) target CANNOT be invited. [HttpPost("invite")] public async Task SendGameInvite() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Parse target + instance from form, JSON body, or query - the client's content-type // for this call was unconfirmed and a [FromForm]-only bind silently read 0 (-> spurious // 400 "playerId is required") whenever it wasn't exactly form-encoded. ulong playerId = 0, roomInstanceId = 0; if (Request.HasFormContentType) { // Form keys are case-sensitive; the client's casing here was unconfirmed, so read // both playerId/PlayerId (and the target-id aliases the invite/message APIs use). string FormAny(params string[] keys) { foreach (var k in keys) { var v = Request.Form[k].ToString(); if (!string.IsNullOrWhiteSpace(v)) return v; } return ""; } ulong.TryParse(FormAny("playerId", "PlayerId", "toPlayerId", "ToPlayerId", "id"), out playerId); ulong.TryParse(FormAny("roomInstanceId", "RoomInstanceId"), out roomInstanceId); } else { try { using var reader = new StreamReader(HttpContext.Request.Body); var raw = reader.ReadToEnd(); if (!string.IsNullOrWhiteSpace(raw)) { var jo = Newtonsoft.Json.Linq.JObject.Parse(raw); playerId = (ulong?)jo["playerId"] ?? (ulong?)jo["PlayerId"] ?? 0; roomInstanceId = (ulong?)jo["roomInstanceId"] ?? (ulong?)jo["RoomInstanceId"] ?? 0; } } catch { } } if (playerId == 0 && ulong.TryParse(Request.Query["playerId"], out var qp)) playerId = qp; if (roomInstanceId == 0 && ulong.TryParse(Request.Query["roomInstanceId"], out var qr)) roomInstanceId = qr; Console.WriteLine($"[invite] {account.Id} -> target={playerId} instance={roomInstanceId} form={Request.HasFormContentType}"); if (playerId == 0) { Console.WriteLine($"[invite] {account.Id} REFUSED - no playerId in request"); return BadRequest(new { success = false, error = "playerId is required" }); } if (playerId == account.Id) return BadRequest(new { success = false, error = "You can't invite yourself." }); if (FriendsDB.IsBlockedEitherWay(account.Id, playerId)) { Console.WriteLine($"[invite] {account.Id} REFUSED - one of them has blocked the other ({playerId})"); return Ok(new { success = false, error = "You can't invite that player." }); } // Do Not Disturb (website setting): the target only takes invites from people they favourited. if (!DoNotDisturb.InviteAllowed(playerId, account.Id)) { Console.WriteLine($"[invite] {account.Id} REFUSED - target {playerId} has Do Not Disturb on"); return Ok(new { success = false, error = "That player isn't taking invites right now." }); } // Offline / appearing offline -> not invitable (the owner's rule). Logged so a wrongly // "offline" target - e.g. one whose notify socket dropped on a restart - is visible. var (targetOnline, _) = HeartbeatDB.PeekPresence(playerId); if (!targetOnline) { Console.WriteLine($"[invite] {account.Id} REFUSED - target {playerId} reads offline (no live notify socket)"); return BadRequest(new { success = false, error = "That player is offline." }); } // The instance to invite into: the one the client named, else the caller's current room. // Unlike before, a missing instance no longer 400s - the reference records + sends the // invite anyway (RoomId/Name just ride empty), so an invite from a dorm/loading caller // still reaches the target. var instance = HeartbeatDB.FindInstanceById((long)roomInstanceId) ?? HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance; var invite = RoomInvites.Create(account.Id, playerId); // GameInviteV2 (type 6) for this client build: Data is JSON-in-a-string; RoomId rides on // the message. InviteMode 22 = PlayTogether (RecFlare GAME_INVITE_V2_INVITE_MODE). string data = JsonConvert.SerializeObject(new { InviteId = invite.RoomInviteId, Name = instance?.name ?? "", InviteMode = 22 }); var msg = new MessageData { Id = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), FromPlayerId = account.Id, ToPlayerId = playerId, SentTime = DateTime.UtcNow, Type = WebsocketEvents.MessageType.GameInviteV2, Data = data, RoomId = (ulong)(instance?.roomId ?? 0), PlayerEventId = null }; try { var frame = JsonConvert.SerializeObject(WebsocketEvents.CreateMessageResponse(msg)); await Notifications.SendToPlayer(playerId, frame); } catch (Exception ex) { Console.WriteLine($"[invite] push to {playerId} failed: {ex.Message}"); } // The invitee's client checks the inviter's presence before it will accept. Push it // now (the invite makes the inviter visible to them - see GetPresenceForList). try { await Notifications.PushPresenceTo(playerId, account.Id); } catch { } Console.WriteLine($"[invite] {account.Id} @{account.Username} invited {playerId} to " + $"^{instance?.name ?? "(no room)"} (instance {instance?.roomInstanceId ?? 0}, inviteId {invite.RoomInviteId})"); return Ok(new { RoomInviteId = invite.RoomInviteId, FromPlayerId = account.Id, ToPlayerId = playerId, RoomId = instance?.roomId ?? 0 }); } // Decline / dismiss an invite (`DELETE /Matchmaking/invite/{inviteId}`) - was 404. Only the // recipient (or the sender, cancelling) may drop it. [HttpDelete("invite/{inviteId}")] public IActionResult DeleteGameInvite(long inviteId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var inv = RoomInvites.Get(inviteId); if (inv != null && (inv.ToPlayerId == account.Id || inv.FromPlayerId == account.Id)) { RoomInvites.Consume(inviteId); Console.WriteLine($"[invite] {account.Id} dismissed invite {inviteId} ({inv.FromPlayerId} -> {inv.ToPlayerId})"); } return Ok(new { success = true }); } // The client's clean-exit signal (`POST /Matchmaking/player/notifydisconnect`) was NOT // implemented (live log: 404), so a graceful quit never cleared the player's presence and // friends kept seeing them "online in a room they aren't in". Clear the heartbeat here so // presence drops immediately on a normal exit (the friends-list read is also live-socket // gated now, but this makes the graceful case instant instead of waiting for the socket to // notice the drop). // The client posts this when its PHOTON connection to the room drops - which happens to // every remaining player the moment the Photon master client (the first one into the // instance) leaves - and then reconnects to the same room by itself. This used to CLEAR // the player's room instance. Their next presence heartbeat then answered "nowhere", and // the client treats that as an eviction: // // [RecNet.Features.Matchmaking] RecNet presence heartbeat response indicates local // presence is out-of-sync! // [RoomLoad.RoomManager.PresenceUpdates] RecNet is directing us to go to an Invalid // RoomInstance. This is RecNet booting us from the room ... so we trigger Fallbacks. // // - and sent itself to the dorm. That is "one player leaves, everyone else gets kicked", // verified in the server log: every "answer changed -> 0 (nowhere)" on // 2026-09-11 followed that same player's own notifydisconnect by 5-40 s. // // The reference (recflare match /player/notifydisconnect) logs it and does nothing: // presence is cleared by logout, and otherwise expires. Same here. A player who really // left is cleared by player/logout or reset by their next login / matchmake, both of // which still run. [HttpPost("player/notifydisconnect")] public IActionResult NotifyDisconnect() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string detail = ""; try { if (Request.HasFormContentType) detail = string.Join("&", Request.Form.Select(kv => $"{kv.Key}={kv.Value}")); } catch { } var current = HeartbeatDB.GetSelfPresence(account.Id).room; Console.WriteLine($"[matchmake] {account.Id} notified a Photon disconnect ({detail}) - presence kept " + $"(instance {current?.roomInstanceId ?? 0}); the client reconnects on its own"); return Ok(new { success = true }); } [HttpGet("player")] [HttpPost("player")] public IActionResult GetPlayerHeartbeatById([FromQuery] List id) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // POST form: the reference's presence heartbeat (a LoginLock guid, verified not // stored) answering the CALLER's own presence; with ids in the body it is the bulk // lookup. Was a 405 either way. if (HttpMethods.IsPost(Request.Method) && (id == null || id.Count == 0)) { var f = RoomModeration.Fields(Request); var ids = RoomModeration.ULongs(f, "id", "ids", "playerIds", "PlayerIds"); if (ids.Count > 0) id = ids; else return Ok(HeartbeatDB.GetPlayerHeartbeat(account.Id)); } if (id == null || id.Count == 0) { return Ok(ConfigCampus.Bracket); } // The caller is the VIEWER. Precompute who they're friends with / have favorited once, // so each target's "Appear Online To" (statusVisibility) can be honored relative to // them (Friends-only / Favorites-only / No One) without a lookup per target. var rels = FriendsDB.GetRelationships(account.Id); var friendIds = rels.Where(r => r.RelationshipType == RelationshipType.Friend) .Select(r => r.PlayerID).ToHashSet(); var favIds = rels.Where(r => r.Favorited != ReciprocalStatus.None) .Select(r => r.PlayerID).ToHashSet(); var heartbeats = new List(); foreach (var playerId in id) { // Friends/player-list presence for OTHERS: live-socket gated (so a crashed/closed // player shows OFFLINE with no room instead of "online in a room they aren't in") // AND status-visibility honored relative to the viewer. The caller's OWN // authoritative heartbeat (which must never answer "nowhere") is served by // player/heartbeat, not here. var heartbeat = HeartbeatDB.GetPresenceForList( playerId, viewerIsSelf: playerId == account.Id, viewerIsFriend: friendIds.Contains(playerId), viewerIsFavorite: favIds.Contains(playerId), viewerId: account.Id); if (heartbeat != null) heartbeats.Add(heartbeat); } return Ok(heartbeats); } [HttpGet("player/qos")] public async Task GetPlayerQos() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string path = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "QOS.json"); string content = await System.IO.File.ReadAllTextAsync(path); // Was: Ok(). ASP.NET's StringOutputFormatter writes a raw string as // Content-Type: text/plain, so these JSON payloads were served mislabelled // and the client would not parse them. Verified live: /outfits/me returned // text/plain while /api/avatar/v2 returned application/json. Same bytes, // correct content type. return new ContentResult { Content = content, ContentType = "application/json", StatusCode = 200 }; } [HttpGet("player/connection-info")] public IActionResult GetPlayerConnectionInfo([FromQuery] ulong roomInstanceId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var heartbeat = HeartbeatDB.GetPlayerHeartbeat(account.Id); // Answer about the instance the client ACTUALLY ASKED FOR. // // This used to ignore roomInstanceId entirely and reply with whatever room the // caller's own heartbeat pointed at. While every public instance shared the // Photon room name "room" that never showed up - any answer put you in the same // room. Now that names are per-instance, replying about the wrong instance // sends the client to a different Photon room mid-load, and it dies with // "Failed to load room due disconnect during load (Code 8706-7)". // // Falls back to the caller's own instance when the id is unknown (0, or an // instance nobody is in yet), which is the old behaviour. var requested = HeartbeatDB.FindInstanceById((long)roomInstanceId); var instance = requested ?? heartbeat?.roomInstance; string photonRoom = instance?.photonRoomIdYeee ?? $"solo-{account.Id}"; if (requested == null && roomInstanceId != 0) Console.WriteLine($"[connection-info] {account.Id} asked about instance {roomInstanceId} " + $"- not found, falling back to their own ({instance?.roomInstanceId})"); // Tachyon voice is only handed out for a room with a REAL instance id, exactly like the // reference's tachyonServerFor (roomInstanceId <= 0 -> empty -> Photon). The dorm/home // is instance -1 (no live instance), so it MUST stay on Photon; advertising Tachyon for // it made the client fail to connect to voice and blocked the whole room load (Code // 8719). Public/created rooms have a positive instance and get Tachyon. long voiceInstanceId = instance?.roomInstanceId ?? 0; bool useTachyon = !string.IsNullOrEmpty(ConfigCampus.TachyonVoiceEndpoint) && voiceInstanceId > 0; string voiceConn = useTachyon ? ConfigCampus.TachyonVoiceEndpoint : ""; string voiceSrv = useTachyon ? ConfigCampus.TachyonVoiceServerId : ""; Console.WriteLine($"[conn-info] acct={account.Id} instance={voiceInstanceId} " + $"voice='{voiceConn}' serverId='{voiceSrv}' tachyonCfg='{ConfigCampus.TachyonVoiceEndpoint}'"); // BOTH shapes in one body. This build's DTO is documented as a BARE 9-key object // (wire-shapes: player-connection-info) while the envelope below is what has actually // been serving working Photon joins here - Utf8Json drops whichever set it does not // know, so emitting both cannot break either reading. var connExperiments = new { NetworkTransformSyncInterval = 10.0, ShouldUseUnreliableOnChange = false, ShouldAvoidDiscontinuityRPCs = true, ShouldAvoidRedundantDiscontinuity = false, R2RuntimeStaticBaking = true, R2AutoEmbodiment = true, R2RuntimeStaticBakingMinShapeThreshold = 1, ShouldUseGameServerNetworking = false }; return Ok(new { PhotonAuthToken = ClientSecurity.Createphotonauthtoken(account.Id, photonRoom), PhotonRealtimeAppId = ConfigCampus.PhotonRealtimeId, PhotonVoiceAppId = ConfigCampus.PhotonVoiceId, PhotonChatAppId = ConfigCampus.PhotonChatId, PhotonRegion = "us", PhotonRoomId = photonRoom, VoiceConnectionInfo = voiceConn, VoiceServerId = voiceSrv, Experiments = connExperiments, success = true, value = new { photonAuthToken = ClientSecurity.Createphotonauthtoken(account.Id, photonRoom),// "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTAzMjY5NzUwIiwicm4ucGxhdGlkIjoiNzY1NjExOTk2MTM4Mzg5MjgiLCJybi5wbGF0IjoiMCIsInJuLmRldmljZWNsYXNzIjoiMiIsInJuLmVudiI6InByb2QiLCJleHAiOjE3NTQzMTYzNTMsImF1ZCI6ImM4YWFhODYwLTFjMGMtNDMzOC1iYWYyLWE1NTNjMDFiMmE1MyJ9.qY_O0tNeZeH2QKuPUIQPhRPnOpp2uhZn3b8eo-ybV3w", photonRealtimeAppId = ConfigCampus.PhotonRealtimeId, photonVoiceAppId = ConfigCampus.PhotonVoiceId, photonChatAppId = ConfigCampus.PhotonChatId, photonRegion = "us", // qos us-east1 photonRoomId = photonRoom, // voiceConnectionInfo & voiceServerId: EMPTY STRINGS, matching RecFlare, which // sets both to varOr(TACHYON_HOST_PORT/NAME, '') - i.e. "" when no dedicated // (Tachyon) voice server is configured. Its comment: "Empty rather than null: // the client's decoder is likelier to accept a missing-value string than a null // on a string field." With no dedicated server named, the client falls back to // PHOTON VOICE (photonVoiceAppId above) for peer-to-peer voice in photonRoomId - // which is how players actually hear each other. // // Earlier tries here were "{}" (an OBJECT - parsed as a voice-server DTO, found // nothing, and aborted with "Code 8719" blocking room load) and then null // (which just skipped voice entirely - mic worked, nobody could hear you). The // empty STRING is the value the reference uses and neither of those was. // // TACHYON: when a dedicated voice server is configured (ConfigCampus // .TachyonVoiceEndpoint = ":"), hand the client that endpoint so it // routes voice through Tachyon instead of Photon P2P. voiceServerId must be a // non-empty id alongside it; the client uses voiceConnectionInfo (":") // to reach the server. Endpoint empty -> both stay "" -> Photon fallback (known // good). Tachyon resolves each player's room via GET /tachyon?id= // (TachyonController) so voice is scoped to the room they're actually in. voiceConnectionInfo = voiceConn, voiceServerId = voiceSrv, experiments = new { networkTransformSyncInterval = 10.0, shouldUseUnreliableOnChange = false, shouldAvoidDiscontinuityRPCs = true, shouldAvoidRedundantDiscontinuity = false, r2RuntimeStaticBaking = true, r2AutoEmbodiment = true, r2RuntimeStaticBakingMinShapeThreshold = 1, r2UseCheapReplicas = true, shouldUseGameServerNetworking = false // true make it connect to 127.0.0.1:7777 qos, false it connects to photon servers ns.exitgames.com } }, // Match the reference envelope EXACTLY: { success, value, error }. The reference // always includes error: null (see match openapi ConnectionInfoResponse). We were // omitting it. The client may validate the whole envelope before trusting // value.voiceConnectionInfo, so a missing `error` can make it drop the voice info // ("No VoiceConnectionInfo") while Photon still connects via a looser read. error = (object?)null }); } [HttpPut("player/gameserverregionpings")] public IActionResult UpdatePlayerGameServerRegionPings() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(""); } // Client publishes its measured Photon-region pings here so the server can pick the // closest region for matchmaking. We don't do region-aware matchmaking (single Photon // Cloud app, no region selection), so accept + discard. Returning 404 leaves the // client stuck in a "still measuring pings" state and delays room joins. [HttpPut("player/photonregionpings")] [HttpPost("player/photonregionpings")] public IActionResult UpdatePhotonRegionPings() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; return Ok(""); } [HttpPost("roominstance/{roomInstanceId}/reportjoinresult")] public IActionResult ReportJoinResultForRoomInstanceId(ulong roomInstanceId) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // The client reports how joining went (e.g. FailedToSpawnPlayer / code 8718 after a room load that threw). It used to be ignored, // so a room that "kicks you to your dorm" left nothing on the server side to look at. try { var f = RoomModeration.Fields(Request); Console.WriteLine($"[joinresult] {account.Id} instance {roomInstanceId}: {RoomModeration.Dump(f)}"); } catch { } return Ok(); } } }