using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json.Serialization; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Controllers.API.Messages.V2; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Match; /// /// match.rec.net/goto/* — matchmaking endpoints used by /// RecNet.Matchmaking.GoToHelper / GoToDormRoom (the latter is what /// BootSequence.LoadDormRoom invokes during initial scene load). /// /// Response shape verified by disassembling /// RecNet.Matchmaking.MatchmakingResponse.Deserialize (RVA 0x1447DD0): /// "errorCode" - int, optional (Util.GetKeyOrDefault<int>) /// "roomInstance" - RoomInstance object, REQUIRED. The deserializer /// throws explicitly with the error /// "Matchmaking response has no RoomInstance field" /// when this key is missing or non-object. /// /// The nested RoomInstance shape (RVA 0x114DAB0) requires: /// roomInstanceId, roomId, subRoomId, location, photonRegionId, /// photonRoomId, maxCapacity, isFull, isPrivate, isInProgress /// (all required) plus optional dataBlob, eventId, name. /// /// The client then JOINS the Photon room named by photonRoomId in the /// region photonRegionId — these two values are what tie our HTTP /// matchmaking to the actual realtime networking. /// [ApiController] public class GoToController( IConfiguration config, PlayerPresenceService presence, PrivateInstanceService privateInstances, RoomService rooms, DorkNet.Server.Data.DorkNetDbContext db, NotificationService notifications, SystemNotificationService systemNotifications, JoinTimeoutService joinTimeouts, ServerSettingsService serverSettings, PresenceBroadcastService presenceFanout, ILogger logger) : ControllerBase { [HttpPost("/goto/room/{roomName}")] public async Task> GoToRoom( string roomName, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, // The December watch signals "make a new instance" via the // JoinMode form field, NOT CreatePrivateInstance (which it never // sends — verified on the wire: a "New Private Instance" request // carries `JoinMode=2` and no CreatePrivateInstance key). Maps to // RecNet.Matchmaking's GIJOGKJCEKG enum: // 0 = PublicMatchmaking (join the existing shared instance) // 1 = PublicNewInstance (fork a fresh PUBLIC instance) // 2 = PrivateNewInstance (fork a fresh, invite-gated instance) // Without honouring this, JoinMode 1/2 fell through to the // default branch and returned the SAME roomInstanceId+photonRoomId // the player was already standing in. Per the sub-room note below, // identical instance id + photon room => the watch's transition // state machine never fires and "Going to " hangs forever // (the player softlocks; the join-watchdog then times out at 30s). [FromForm(Name = "JoinMode")] int joinMode = 0, // Party members the watch wants to bring along (added as repeated // `AdditionalPlayerIds` form fields by RecNet.Matchmaking — verified // at Matchmaking.txt:5798-5806). Each gets a follow invite once the // leader's new instance is built. See NotifyAdditionalPlayersAsync. [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) { // Unknown room name shouldn't silently drop the player into a // synth'd dorm scene any more — that masked typo bug reports // ("why does /goto/^Crescendoo land me in my dorm?") and let // dead links sit unfixed. Surface the real "no such room" // error so the watch shows its standard "room not found" toast // and the typo is obvious. DormRoom is a special case (per-player // lazy-create), everything else MUST exist in db.Rooms. if (!roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) { var exists = await rooms.GetByNameAsync(roomName); if (exists is null) { logger.LogWarning("[goto] unknown room name='{Name}' — returning RoomDoesNotExist", roomName); return Ok(new MatchmakingResponseDto { // 4 = RoomDoesNotExist on // RecNet.Matchmaking.CreateModifyRoomStatus enum. // The watch reads ErrorCode != 0 and surfaces the // standard "Room not found" toast. ErrorCode = 4, RoomInstance = null, }); } } var resp = await BuildResponseAsync(roomName); await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode); await RecordResponseAsync(resp); await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); return Ok(resp); } [HttpPost("/goto/room")] public async Task> GoToRoomFromBody( [FromForm(Name = "RoomName")] string? roomNameForm, [FromForm(Name = "roomName")] string? roomNameLower, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, [FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) { var roomName = roomNameForm ?? roomNameLower ?? Request.Query["RoomName"].FirstOrDefault() ?? Request.Query["roomName"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(roomName)) return BadRequest(new { error = "missing roomName" }); return await GoToRoom(roomName, createPrivateInstance, joinMode, additionalPlayerIds); } [HttpPost("/goto/none")] public IActionResult GoToNone() => Ok(); /// POST /goto/room/{room}/{subRoom} — sub-room form /// from RecNet.Matchmaking.GoToSubRoom /// (Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702): /// String.Concat("goto/room/", urlEncode(room), "/", urlEncode(subRoom)). /// Used by baked AG-Original rooms with multiple scenes (e.g. /// StuntRunner/TheMainEvent, the playable gauntlet inside /// the StuntRunner lobby). /// /// We resolve to the parent room's for /// the canonical RoomId + LocationReplicationId, then expose the /// sub-room name via the name field so the client can /// render the right scene. If we ever build a per-sub-room scene /// location lookup table, switch /// to the matching scene's ReplicationId. [HttpPost("/goto/room/{roomName}/{subRoomName}")] public async Task> GoToSubRoom( string roomName, string subRoomName, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, // See GoToRoom: JoinMode 1/2 = fork a fresh public/private // instance of this sub-room rather than rejoining the shared one. [FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) { // Build (don't record yet) — we need to mutate the RoomInstance // for the sub-room before presence is recorded, otherwise the // server presence has the lobby's RoomInstance but the client // thinks it's in the sub-room → heartbeat sees a mismatch and // the watch logs "presence out-of-sync" / Error -2. var resp = await BuildResponseAsync(roomName); // Imported multi-scene rooms (admin Import Room) get one // RoomSceneEntity row per chapter — prefer that when the // (room, subRoom) pair matches. Falls back to the hardcoded // baked-room map for AG-Originals like StuntRunner/TheMainEvent. // // Lookup is tolerant: in-game portals don't always pass the // canonical scene Name. We match in this order: // 1. exact (case-insensitive — RoomScenes.Name is NOCASE collated) // 2. EndsWith(subRoomName) — handles portals that omit // a "Ch0_" / "Chapter1_" prefix // 3. Contains(subRoomName) — last-resort fuzzy match // and fall back to the hardcoded location map only when no // RoomScenes row matches at all. var allScenes = await db.RoomScenes .Where(s => s.RoomId == resp.RoomInstance.RoomId) .Select(s => new { s.OrderIndex, s.Name, s.RoomSceneLocationId, s.DataBlobName }) .ToListAsync(); var dbScene = allScenes.FirstOrDefault(s => string.Equals(s.Name, subRoomName, StringComparison.OrdinalIgnoreCase)) ?? allScenes.FirstOrDefault(s => s.Name.EndsWith(subRoomName, StringComparison.OrdinalIgnoreCase)) ?? allScenes.FirstOrDefault(s => s.Name.IndexOf(subRoomName, StringComparison.OrdinalIgnoreCase) >= 0); long subId; string? subLocation; string? subDataBlob; if (dbScene is not null) { logger.LogInformation( "[goto-subroom] {Room}/{Requested} → matched scene '{MatchedName}' idx={Idx} loc={Loc} blob={Blob}", roomName, subRoomName, dbScene.Name, dbScene.OrderIndex, dbScene.RoomSceneLocationId, dbScene.DataBlobName); subId = dbScene.OrderIndex; subLocation = dbScene.RoomSceneLocationId; subDataBlob = dbScene.DataBlobName; } else { logger.LogWarning( "[goto-subroom] {Room}/{Requested} — no RoomScenes match (room has {Count} scenes: {Names}). Falling back to hardcoded MapSubRoomToLocationId.", roomName, subRoomName, allScenes.Count, string.Join(", ", allScenes.Select(s => s.Name))); subId = Math.Abs((long)subRoomName.GetHashCode()); subLocation = MapSubRoomToLocationId($"{roomName}/{subRoomName}".ToLowerInvariant()); subDataBlob = null; } // Both PhotonRoomId AND RoomInstanceId must differ for the // watch's transition state machine to fire. Empirical from // comparing the two failure modes: // Same photon + same roomInstanceId → blob downloads (because // dataBlob field changed) but watch never transitions; the // "Going to X" dialog hangs because nothing the state // machine tracks signalled "I'm in a different match". // Different photon + same roomInstanceId → watch doesn't // even fetch the new dataBlob; it sees roomInstanceId // unchanged so treats the response as a no-op presence // update and skips the join flow. // Folding subId into RoomInstanceId AND forking PhotonRoomId // gives the watch the two signals it needs: "new instance" // (roomInstanceId differs → reset state machine) plus "new // photon match" (photonRoomId differs → leave/rejoin → // SceneLoaded events that ultimately dismiss the dialog). ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob); // Private/new-instance mutation goes AFTER sub-room mutation so the // PhotonRoomId we register includes the _sub{N} suffix as its // base — invitees coming in via /goto/instance/{id} land in the // exact same sub-room photon match, not the lobby's. await ApplyNewInstanceAsync(resp, subRoomId: subId, createPrivateInstance, joinMode); await RecordResponseAsync(resp); await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); return Ok(resp); } /// Mutate in place so it represents /// the given sub-scene of its parent room. Both PhotonRoomId AND /// RoomInstanceId must differ from the lobby's or the watch's /// transition state machine never fires: /// • Same photon + same roomInstanceId → the new dataBlob downloads /// but the watch never transitions; "Going to X" hangs forever. /// • Different photon + same roomInstanceId → the watch sees the id /// unchanged, treats the response as a no-op presence update and /// skips the join flow entirely. /// Folding subId into RoomInstanceId AND forking PhotonRoomId gives /// the watch both signals it needs ("new instance" + "new photon /// match"). dataBlob tells the client which PersistedRoomData to /// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0, /// reads it with a string-with-default reader so empty is legal) — /// but an empty blob makes the watch fall back to the parent room's /// lobby blob, so the sub-scene never changes visually. Only override /// location/blob when we actually resolved one. private static void ApplySubRoomMutation( MatchmakingResponseDto resp, long subId, string subRoomName, string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null) { resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId; resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}"; resp.RoomInstance.Name = subRoomName; resp.RoomInstance.SubRoomId = subId; if (subLocation is not null) resp.RoomInstance.Location = subLocation; if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty; } /// POST match.*/matchmake/room/{roomId} — the 2023 /// client's primary "join this public room" call /// (RecNet.Matchmaking, format string "matchmake/room/{0}", /// same sender HHJFFJEHLOB as the working matchmake/dorm, /// so it's a POST returning ). /// It navigates by NUMERIC roomId straight from the room tile, NOT by /// name like the older /goto/room/{name} path. With no route /// here every public-room join 404s and the watch bounces the player /// back to their dorm ("Matchmaking request failed /// (matchmake/room/N): HTTP Error 404"). We resolve the id to its /// room and reuse so dorm/private/ /// dataBlob/JoinMode handling stays identical to . /// GET is accepted too as cheap insurance against a verb mismatch. [HttpPost("/matchmake/room/{roomId:long}")] [HttpGet("/matchmake/room/{roomId:long}")] public async Task> MatchmakeRoom( long roomId, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, [FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) { var room = await rooms.GetByIdAsync(roomId); if (room is null) { logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId); // 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows // its standard "room not found" toast instead of softlocking. return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null }); } var resp = await BuildResponseAsync(room.Name); await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode); await RecordResponseAsync(resp); await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); logger.LogInformation( "[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}", this.CurrentPlayerId(), roomId, room.Name, resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId); return Ok(resp); } /// POST match.*/matchmake/room/{roomId}/{subRoomId} — /// sub-room form (RecNet.Matchmaking "matchmake/room/{0}/{1}"). /// Unlike (which takes a sub-room NAME and /// fuzzy-matches it) the sub-room arrives here as a numeric /// , so we resolve the scene /// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene — /// the base response already represents it, so we skip the /// mutation. [HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")] [HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")] public async Task> MatchmakeSubRoom( long roomId, long subRoomId, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, [FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) { // The client sends the wire SubRoomId it read from the room details // (OrderIndex + 1, see RoomSceneIds); everything below works in // OrderIndex. subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); var room = await rooms.GetByIdAsync(roomId); if (room is null) { logger.LogWarning( "[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist", roomId, subRoomId); return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null }); } var resp = await BuildResponseAsync(room.Name); if (subRoomId != 0) { var scene = await db.RoomScenes.AsNoTracking() .Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId) .Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId }) .FirstOrDefaultAsync(); if (scene is not null) { logger.LogInformation( "[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}", roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId); ApplySubRoomMutation( resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId); } else { logger.LogWarning( "[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " + "forking ids onto the lobby scene so the watch still transitions", roomId, subRoomId); // Reuse the lobby location/blob (sub-scene won't differ // visually) but still fold subId in so the watch leaves // the current match instead of softlocking. ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null); } } await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode); await RecordResponseAsync(resp); await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); return Ok(resp); } /// Per-sub-room scene location overrides for baked /// AG-Original rooms with multiple scenes. Each entry maps a /// "{room}/{subRoom}" key (lowercase) to the /// RoomSceneLocationId GUID baked into the client's /// AGRoomRuntimeConfig.Locations table. Without an entry the /// parent room's location is reused (lobby loads, door opens /// but transition no-ops). /// /// Add entries here as you confirm each sub-scene's GUID — the /// client's AGRoomSettings.TryGetRoomSceneLocationById /// rejects unknown GUIDs with "RecNet game session contains /// unknown scene location ID" and refuses to load. /// Sub-room scene GUIDs extracted from the bundled /// resources.assets AGRoomRuntimeConfig.Rooms[].Scenes[] /// using tools/extract-rooms-binary.py. Each entry maps /// "{room}/{subRoom}" (lowercase) to the target /// RoomSceneLocationId GUID. Missing sub-rooms reuse the /// parent room's lobby location (door opens but transition no-ops). private static string? MapSubRoomToLocationId(string subKey) => subKey switch { // ── StuntRunner ── // Lobby is the implicit Scene[0]; TheMainEvent is the // playable gauntlet behind the door. "stuntrunner/themainevent" => "3a636bd2-f896-424c-9225-c184522c0d87", // ── Paintball (River map + variants) ── // The Paintball Room has nested sub-rooms for each map; the // lobby acts as a hub and players pick a map from a board. "paintball/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "paintball/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", "paintball/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", "paintball/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", "paintball/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", "paintballvr/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "paintballvr/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", "paintballvr/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", "paintballvr/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", "paintballvr/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", // ── LaserTag ── "lasertag/cyberjunkcity" => "9d6456ce-6264-48b4-808d-2d96b3d91038", _ => null, }; [HttpPost("/goto/event/{eventId:long}")] // 2023 rename goto/event → matchmake/event (RecNet.Matchmaking HHDMGIMLBKF). [HttpPost("/matchmake/event/{eventId:long}")] public async Task> GoToEvent(long eventId) { // Matchmake the caller into the event's host room. Falls back to // the dorm if the event is unknown or has no room set. var roomId = await db.PlayerEvents.AsNoTracking() .Where(e => e.Id == eventId) .Select(e => (long?)e.RoomId) .FirstOrDefaultAsync(); string? roomName = null; if (roomId is long rid && rid > 0) roomName = await db.Rooms.AsNoTracking() .Where(r => r.Id == rid) .Select(r => r.Name) .FirstOrDefaultAsync(); var resp = await BuildResponseAsync( string.IsNullOrWhiteSpace(roomName) ? "DormRoom" : roomName); // joinMode 0 is a no-op for public rooms; it only funnels private // rooms into their single per-subroom instance. await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } [HttpPost("/goto/club/{clubId:long}")] // 2023 rename goto/club → matchmake/club (RecNet.Matchmaking KPAIGCCHIBN). [HttpPost("/matchmake/club/{clubId:long}")] public async Task> GoToClub(long clubId) { var club = await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId); if (club is null) { return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = new RoomInstanceDto(), }); } // Land in the club's clubhouse room when it has one set; otherwise // fall back to the Rec Center. Either way the response carries the // ClubId so the watch's club UI knows which club it matchmade for. string roomName = "RecCenter"; if (club.ClubhouseRoomId is long clubhouseId && clubhouseId > 0) { var name = await db.Rooms.AsNoTracking() .Where(r => r.Id == clubhouseId) .Select(r => r.Name) .FirstOrDefaultAsync(); if (!string.IsNullOrWhiteSpace(name)) roomName = name; } var resp = await BuildResponseAsync(roomName); resp.RoomInstance!.ClubId = club.Id; // Funnel private clubhouse rooms into their single instance. await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } [HttpPost("/goto/code")] [HttpPost("/goto/code/{code}")] // 2023 sends the room code as the second segment behind the room id // (RecNet.Matchmaking CPINEGCODMD → "matchmake/code/{roomId}/{code}"). // The code alone resolves the private instance, so the roomId is just // routing context we don't need — resolve by code either way. [HttpPost("/matchmake/code/{roomId:long}/{code}")] public async Task> GoToCode(string? code = null, long roomId = 0) { code ??= Request.HasFormContentType ? (await Request.ReadFormAsync())["code"].FirstOrDefault() : Request.Query["code"].FirstOrDefault(); code = RoomCodeService.Normalize(code); if (string.IsNullOrWhiteSpace(code)) { return Ok(new MatchmakingResponseDto { ErrorCode = 40, RoomInstance = new RoomInstanceDto(), }); } var privateIds = await db.PrivateInstances.AsNoTracking() .Select(p => p.Id) .ToListAsync(); var instanceId = privateIds.FirstOrDefault(id => RoomCodeService.Generate(id) == code); if (instanceId == 0) { return Ok(new MatchmakingResponseDto { ErrorCode = 40, RoomInstance = new RoomInstanceDto(), }); } var resp = await BuildPrivateInstanceJoinResponseAsync(instanceId); await RecordResponseAsync(resp); return Ok(resp); } [HttpPost("/goto/playlist")] [HttpPost("/goto/playlist/{playlist}")] public async Task> GoToPlaylist(string? playlist = null) { playlist ??= Request.Query["playlist"].FirstOrDefault() ?? Request.Query["playlistId"].FirstOrDefault(); var value = (playlist ?? string.Empty).Trim(); var roomName = "RecCenter"; if (!string.IsNullOrWhiteSpace(value)) { var lowered = value.ToLowerInvariant(); var room = await db.Rooms.AsNoTracking() .Where(r => r.Accessibility == 2 && (r.Name.ToLower().Contains(lowered) || r.TagsCsv.ToLower().Contains(lowered))) .OrderByDescending(r => r.HotScore) .FirstOrDefaultAsync(); if (room is not null) roomName = room.Name; } var resp = await BuildResponseAsync(roomName); // Funnel private rooms into their single per-subroom instance. await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); await RecordResponseAsync(resp); return Ok(resp); } /// POST /goto/instance/{roomInstanceId} — invite- /// accept route. The watch posts here when joining a private /// match via a Message-attached invite. We resolve the instance /// id to the registered /// and return a RoomInstance pinned to the inviter's PhotonRoomId /// so both players land in the same Photon match. Falls back to /// dorm if the instance was forgotten (server restart, owner /// already left and nobody re-created it). [HttpPost("/goto/instance/{roomInstanceId:long}")] // The 2023 client renamed the whole matchmaking surface from goto/* to // matchmake/* (verified 2023.03.21 il2cpp dump — RecNet.Matchmaking // ANOAAIHGHMG builds "matchmake/instance/{0}"). Same handler, both // mounts, so December and 2023 clients each hit the path they build. [HttpPost("/matchmake/instance/{roomInstanceId:long}")] public async Task> GoToInstance(long roomInstanceId) { var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId); await RecordResponseAsync(resp); return Ok(resp); } /// POST matchmake/chatinvite/{accountId}/{roomInstanceId} /// — the 2023 "pull into my match" path. When a party owner invites /// someone over chat (PlayerParty.AcceptChatPartyInvite → /// RecNet.Matchmaking chatinvite, verified in the 2023.03.21 il2cpp /// dump), the invitee's watch posts here to matchmake into the owner's /// instance. The two path params are the inviter's account id and the /// room instance id in an order the client doesn't document; a private /// instance id is always ≥ 2^24 while account ids are small sequential, /// so we take the param that resolves to a registered instance (falling /// back to the larger value). Unlike the message-based /// POST /invite flow, a chat party invite writes no invitee row, /// so we add the caller to the allow-list here before resolving the /// join — the owner explicitly pulled them in. [HttpPost("/matchmake/chatinvite/{p0:long}/{p1:long}")] public async Task> MatchmakeChatInvite(long p0, long p1) { var roomInstanceId = await ResolveChatInviteInstanceAsync(p0, p1); if (this.CurrentPlayerId() is long callerId && roomInstanceId > 0) await privateInstances.InviteAsync(roomInstanceId, callerId); var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId); await RecordResponseAsync(resp); logger.LogInformation( "[matchmake-chatinvite] caller={Caller} p0={P0} p1={P1} → instance={Instance} error={Error}", this.CurrentPlayerId(), p0, p1, roomInstanceId, resp.ErrorCode); return Ok(resp); } /// Pick the room-instance param out of a chatinvite's two path /// segments. Prefers whichever resolves to a registered private /// instance; if neither does (e.g. a public-room party pull whose id is /// synthetic), falls back to the larger value since instance ids dwarf /// account ids. private async Task ResolveChatInviteInstanceAsync(long p0, long p1) { if (await privateInstances.GetAsync(p0) is not null) return p0; if (await privateInstances.GetAsync(p1) is not null) return p1; return Math.Max(p0, p1); } [HttpPost("/goto/invite/{inviteId:long}")] // 2023 rename of goto/invite → matchmake/invite (RecNet.Matchmaking // OIEDPBLNMOP). {inviteId} is the invite message id, same as December. [HttpPost("/matchmake/invite/{inviteId:long}")] public async Task> GoToInvite(long inviteId) { var callerId = this.CurrentPlayerId(); if (callerId is null) { var unauth = await BuildResponseAsync("DormRoom"); await RecordResponseAsync(unauth); return Ok(unauth); } var invite = await db.Messages.FirstOrDefaultAsync(m => m.Id == inviteId && m.RecipientPlayerId == callerId.Value && (m.Type == 6 || m.Type == 7 || m.Type == -6 || m.Type == -7)); long? roomInstanceId = null; if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed)) roomInstanceId = parsed; // The 2020 watch's accept-invite flow fires // POST /api/messages/v3/delete AND POST /goto/invite/{id} in // parallel — verified in the live server log: // 09:53:47.161Z DELETE messageIds=[60] // 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found // Current deletes tombstone invite rows so this should only // be needed for invite rows that were hard-deleted by older // builds. The (instance, player) → LatestInviteMessageId // mapping that SendInvite writes onto the // PrivateInstanceInviteeEntity survives those old deletions // and lets us recover roomInstanceId here. if (roomInstanceId is null) { var inviteeRoomInstance = await db.PrivateInstanceInvitees .Where(i => i.PlayerId == callerId.Value && i.LatestInviteMessageId == inviteId) .Select(i => (long?)i.PrivateInstanceId) .FirstOrDefaultAsync(); if (inviteeRoomInstance is long inst) { roomInstanceId = inst; logger.LogInformation( "[goto-invite] message {InviteId} missing (likely raced with DELETE); resolved roomInstance={Instance} via invitee fallback for caller {Caller}", inviteId, inst, callerId); } } if (roomInstanceId is null) { logger.LogWarning( "[goto-invite] invite {InviteId} not found and no invitee fallback for caller {Caller}", inviteId, callerId); return Ok(new MatchmakingResponseDto { ErrorCode = 40, // RoomInviteExpired RoomInstance = new RoomInstanceDto(), }); } // Mark the message read. The watch's parallel DELETE may have // wiped the row between our initial read and this save — // swallow the resulting DbUpdateConcurrencyException since the // accept has already succeeded at this point. if (invite is not null && invite.ReadAt is null) { invite.ReadAt = DateTime.UtcNow; try { await db.SaveChangesAsync(); } catch (DbUpdateException) { db.Entry(invite).State = EntityState.Detached; } } // Two routes: // PRIVATE instance: roomInstanceId resolves to a registered // PrivateInstance → build a join response pinned to its // photonRoomId + invite-list enforcement. // // PUBLIC room: no PrivateInstance for that id (sender was in // a public room / a dorm / etc.). Use the message's RoomId // to look up the room name and route via the standard public // matchmaking path so the recipient lands in the same room // (different instance is fine — public rooms aren't pinned // to one Photon match). var isPrivate = await privateInstances.GetAsync(roomInstanceId.Value) is not null; MatchmakingResponseDto resp; if (isPrivate) { resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value); } else if (invite?.RoomId is long publicRoomId && publicRoomId > 0) { var roomName = await db.Rooms .Where(r => r.Id == publicRoomId) .Select(r => r.Name) .FirstOrDefaultAsync(); if (string.IsNullOrWhiteSpace(roomName)) { logger.LogWarning( "[goto-invite] invite {InviteId} → no PrivateInstance and Message.RoomId={RoomId} doesn't resolve to a Rooms row; routing to dorm", inviteId, publicRoomId); resp = await BuildResponseAsync("DormRoom"); } else { logger.LogInformation( "[goto-invite] public-room invite {InviteId} → routing caller {Caller} to {RoomName} (RoomId={RoomId})", inviteId, callerId, roomName, publicRoomId); resp = await BuildResponseAsync(roomName); // Private rooms pin to their single per-subroom instance so // the invitee lands with the inviter, not in a parallel copy. await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance: false, joinMode: 0); } } else { // No private instance and the message has no RoomId. This // shouldn't happen with the current SendInvite (it always // stamps RoomId), but legacy rows from before the public- // room invite fix may not have one. Fall back to dorm. logger.LogWarning( "[goto-invite] invite {InviteId} → no PrivateInstance and no Message.RoomId; routing to dorm", inviteId); resp = await BuildResponseAsync("DormRoom"); } await RecordResponseAsync(resp); return Ok(resp); } private async Task BuildPrivateInstanceJoinResponseAsync(long roomInstanceId) { var inst = await privateInstances.GetAsync(roomInstanceId); if (inst is null) { logger.LogWarning( "[goto-instance] {Id} → no registered private instance; routing to dorm", roomInstanceId); return await BuildResponseAsync("DormRoom"); } // Enforce invite-list membership. Owner is always allowed (so // they can rejoin their own match after a disconnect). Anyone // else needs to have been invited via POST /invite. We refuse // with errorCode != 0 so the watch surfaces a "join failed" // toast instead of silently routing to the wrong photon room. if (this.CurrentPlayerId() is long callerId && !await privateInstances.CanJoinAsync(roomInstanceId, callerId)) { logger.LogWarning( "[goto-instance] {Id} → caller {Caller} is not on the invite list (owner={Owner}); rejecting", roomInstanceId, callerId, inst.OwnerPlayerId); // MatchmakingErrorCode 26 = RoomInstanceIsPrivate — the // dedicated "you're not invited" error from the // RecNet.Matchmaking+MatchmakingErrorCode enum (verified in // Cpp2IL_CS/.../Matchmaking.cs line 166). Surfaces a clearer // "this is a private match" message on the watch than // 9 = RoomNotJoinable does. return new MatchmakingResponseDto { ErrorCode = 26, RoomInstance = new RoomInstanceDto(), }; } return new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = new RoomInstanceDto { RoomInstanceId = inst.InstanceId, RoomId = inst.RoomId, SubRoomId = inst.SubRoomId, Location = inst.Location, PhotonRegionId = inst.PhotonRegion, PhotonRoomId = inst.PhotonRoomId, Name = inst.Name, MaxCapacity = inst.MaxCapacity, IsPrivate = true, IsInProgress = true, DataBlob = inst.DataBlob, SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId), }, }; } // Invite-message Body can be any of three shapes depending on how / // when it was written: // 1. Modern (current): JSON {"inviteId":N,"name":"…","roomInstanceId":N} // — extract roomInstanceId from the JSON property. // 2. Legacy (pre-V2 fix): bare integer = roomInstanceId. // 3. Ancient (pre-Type-column): text "Invite to private match // (instance N)" — parse the trailing integer out of the literal. // Honour all three so old rows in the DB still resolve. private static bool TryParseRoomInstanceId(string body, out long instanceId) { instanceId = 0; if (string.IsNullOrWhiteSpace(body)) return false; if (body.TrimStart().StartsWith('{')) { try { using var doc = System.Text.Json.JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("roomInstanceId", out var v)) { // String form first — the SendInvite serializer now // emits roomInstanceId as a quoted string because // LitJson on the watch fails to parse a JSON object // that contains a 64-bit numeric literal larger than // int32 (returns null from the dict cast → NRE in // Util.GetKey downstream). Number form stays // supported for any old rows that pre-dated the // stringify fix. if (v.ValueKind == System.Text.Json.JsonValueKind.String && long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var strInst)) { instanceId = strInst; return true; } if (v.ValueKind == System.Text.Json.JsonValueKind.Number && v.TryGetInt64(out var numInst)) { instanceId = numInst; return true; } } } catch { /* fall through to legacy formats */ } } if (long.TryParse(body, out instanceId)) return true; var open = body.IndexOf("instance ", StringComparison.Ordinal); if (open < 0) return false; var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' '); return long.TryParse(tail, out instanceId); } /// POST /goto/player/{playerId} — join the room /// the target player is currently in. /// /// For PUBLIC rooms: mirror the target's live RoomInstance. The /// 2020.12 client reconciles joined players against the exact /// roomInstanceId/photonRoomId pair it got from matchmaking; rebuilding /// a fresh instance here can leave the caller in a different Photon /// shard than the target and null-ref during the follow-join callback. /// /// For PRIVATE rooms: this route must NOT bypass the invite list. /// Earlier we mirrored the target's RoomInstance verbatim under /// the assumption "Go to player only shows up for friends" — /// that's wrong: friend ≠ invited, and the owner picked Private /// specifically to control who joins. Caller must be the owner /// or on the invite list per ; /// otherwise we refuse with errorCode=9 (RoomNotJoinable). [HttpPost("/goto/player/{playerId:long}")] // 2023 rename goto/player → matchmake/player (RecNet.Matchmaking // FJAFOJHHKPO). Follow a friend into their current room/instance. [HttpPost("/matchmake/player/{playerId:long}")] public async Task> GoToPlayer(long playerId) { var theirRoom = presence.GetRoom(playerId); MatchmakingResponseDto resp; if (theirRoom is null) { resp = await BuildResponseAsync("DormRoom"); } else if (theirRoom.IsPrivate) { // RoomInstanceId IS the registry key for private matches — // generated by PrivateInstanceService.Register. Caller must // be invited (or owner) to land in the same Photon room. var callerId = this.CurrentPlayerId(); if (callerId is null || !await privateInstances.CanJoinAsync(theirRoom.RoomInstanceId, callerId.Value)) { logger.LogWarning( "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", callerId, playerId, theirRoom.RoomInstanceId); // Tell the OWNER someone tried to follow them in, as the // client's own "wants to join" message (RequestGameInvite, // from the caller), so they can send an invite. At most once // per requester, owner and instance per cooldown, so retrying // doesn't fill the owner's inbox. if (callerId is long requester && JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId)) await systemNotifications.SendAsync(playerId, SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester); return Ok(new MatchmakingResponseDto { ErrorCode = 26, // RoomInstanceIsPrivate RoomInstance = new RoomInstanceDto(), }); } // Allowed — mirror the target's RoomInstance so we land // in the same Photon match. resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = CloneRoomInstance(theirRoom), }; } else { // Public follow-joins still need to land in the target's // specific live instance, not just the same room name. This // preserves sub-room and Photon shard identity for PUN's // player-enter reconciliation. resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = CloneRoomInstance(theirRoom), }; } await RecordResponseAsync(resp); return Ok(resp); } private static RoomInstanceDto CloneRoomInstance(RoomInstanceDto room) => new() { RoomInstanceId = room.RoomInstanceId, RoomId = room.RoomId, SubRoomId = room.SubRoomId, Location = room.Location, PhotonRegionId = room.PhotonRegionId, PhotonRoomId = room.PhotonRoomId, MaxCapacity = room.MaxCapacity, IsFull = room.IsFull, IsPrivate = room.IsPrivate, IsInProgress = room.IsInProgress, DataBlob = room.DataBlob, SubRoomUnityAssetId = room.SubRoomUnityAssetId, EventId = room.EventId, Name = room.Name, RoomInstanceType = room.RoomInstanceType, ClubId = room.ClubId, RoomCode = room.RoomCode, EncryptVoiceChat = room.EncryptVoiceChat, }; /// Record the just-built response against the bearer-token /// player so the next heartbeat reflects the room they joined. /// Anonymous /goto calls (no JWT) skip recording — the heartbeat is /// authenticated, so a match only happens when the same JWT shows /// up there. Without this, heartbeats return roomInstance:null and /// the watch trips its "presence out-of-sync" detector → "Error: -2". /// Caller is responsible for any sub-room or private-instance /// mutations BEFORE handing the response in here. private async Task RecordResponseAsync(MatchmakingResponseDto response) { if (this.CurrentPlayerId() is not long playerId) return; if (response.RoomInstance is null) return; var targetRoom = response.RoomInstance; var currentRoom = presence.GetRoom(playerId); var deferPresenceCommit = ShouldDeferPresenceCommit(currentRoom, targetRoom); joinTimeouts.MarkPending(playerId, targetRoom, deferPresenceCommit); if (deferPresenceCommit) { logger.LogInformation( "[goto] deferred presence commit player={PlayerId} room={RoomId} subRoom={SubRoomId} fromInstance={FromInstance} toInstance={ToInstance} fromPhoton={FromPhoton} toPhoton={ToPhoton}", playerId, targetRoom.RoomId, targetRoom.SubRoomId, currentRoom?.RoomInstanceId, targetRoom.RoomInstanceId, currentRoom?.PhotonRoomId, targetRoom.PhotonRoomId); return; } presence.SetRoom(playerId, targetRoom); presence.MarkActive(playerId); // Real visit tracking: upsert the per-(room, player) row so // we get distinct VisitorCount (unique players ever) AND the // gross VisitCount (every join, including rejoins). Skip the // canonical dorm (RoomId=1) so per-day boots don't skew it. if (targetRoom.RoomId != 1) await RecordVisitAsync(playerId, targetRoom.RoomId); // Push a presence-update notification to every accepted-friend // so their watch's "friends" list shows the new room. Cheap // query — Relationships is small, and we only fan out to the // actually-connected friends. await NotifyFriendsOfMoveAsync(playerId, targetRoom); // NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom // from /goto. The watch's Rooms.OnSubscriptionUpdateRoom // (Cpp2IL_ISIL/.../RecNet/Rooms.txt:2401-2496) is gated on // (Rooms.cachedRoomId == roomId && Rooms.cachedSubRoomId == subRoomId): // the FIRST push for a (room, subRoom) tuple does the fetch + // set_LocalRoomDetails; subsequent pushes for the same tuple // exit early at line 055, NEVER firing the // RoomDetailsUpdated event. The save coroutine's // RoomDetailsUpdatedCallback is registered to that event // (RoomPersistenceManager+DisplayClass174_0:14-48) and only // sets the roomDetailsUpdated flag when the event fires — // which is the ONLY thing that flips the b__4 predicate true // and dismisses the save spinner. Pre-pushing here primes the // cached tuple so the save's own push gets deduplicated → no // event → spinner wedges forever. Let the save's push be the // first one for any (room, subRoom). } /// Party-follow. When the leader's /goto carries /// AdditionalPlayerIds (party members the watch wants to bring /// along — added as repeated form fields by RecNet.Matchmaking, /// Matchmaking.txt:5798-5806), drop a GameInviteV2 (Type 6) into each /// member's inbox pointing at the leader's freshly-built RoomInstance. /// Mirrors : registers each /// member on the private instance (so /goto/instance|invite accepts /// them) and pushes a MessageReceived notification carrying /// {inviteId,name,roomInstanceId} so their watch's GameInviteController /// follows via /goto/invite/{inviteId}. Without this the leader travels /// alone and the party silently stays behind — the reported bug. We /// notify ONLY the members, not the leader (unlike SendInvite's /// both-sides chat sync): the leader doesn't want an invite to the room /// they're already entering. private async Task NotifyAdditionalPlayersAsync( List? additionalPlayerIds, MatchmakingResponseDto resp) { if (additionalPlayerIds is null || additionalPlayerIds.Count == 0) return; if (resp.RoomInstance is null) return; if (this.CurrentPlayerId() is not long leaderId) return; var targets = additionalPlayerIds .Where(id => id > 0 && id != leaderId) .Distinct() .ToList(); if (targets.Count == 0) return; var instance = resp.RoomInstance; var roomName = !string.IsNullOrWhiteSpace(instance.Name) ? instance.Name : "Room"; foreach (var target in targets) { // Type 6 = RecNet.Message.MessageType.GameInviteV2 — the watch's // GameInviteController turns this into goto/invite/{inviteId}. // First save allocates the Id that BECOMES the inviteId; the // second backfills the JSON Body once we know it (EF identity // columns only populate on SaveChanges). var entry = new MessageEntity { SenderPlayerId = leaderId, RecipientPlayerId = target, Body = string.Empty, Type = 6, RoomId = instance.RoomId, }; db.Messages.Add(entry); await db.SaveChangesAsync(); // Private instances gate joins on the invitee list; register the // member so /goto/instance|invite lets them in. Public rooms have // no registered instance — GoToInvite falls back to the message's // RoomId, so no registration is needed there. if (instance.IsPrivate) await privateInstances.InviteAsync(instance.RoomInstanceId, target, messageId: entry.Id); // roomInstanceId MUST be a JSON STRING — LitJson NREs on a >int32 // long in the dict cast and aborts the whole Message.Deserialize // (see the detailed note in MessagesController.SendInvite). entry.Body = System.Text.Json.JsonSerializer.Serialize(new { inviteId = entry.Id, name = roomName, roomInstanceId = instance.RoomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture), }); await db.SaveChangesAsync(); var dto = new MessagesController.MessageDto( Id: entry.Id, FromPlayerId: entry.SenderPlayerId, SentTime: entry.SentAt, Type: entry.Type, Data: entry.Body, RoomId: entry.RoomId, PlayerEventId: null); await notifications.NotifyAsync(target, PushNotificationId.MessageReceived, dto); } logger.LogInformation( "[goto-party] leader={Leader} room={Room}/\"{Name}\" instance={Instance} isPrivate={Priv} → invited {Count} member(s): {Members}", leaderId, instance.RoomId, roomName, instance.RoomInstanceId, instance.IsPrivate, targets.Count, string.Join(",", targets)); } private static bool ShouldDeferPresenceCommit(RoomInstanceDto? currentRoom, RoomInstanceDto targetRoom) => currentRoom is not null && targetRoom.IsPrivate && currentRoom.RoomId == targetRoom.RoomId && currentRoom.SubRoomId == targetRoom.SubRoomId && (currentRoom.RoomInstanceId != targetRoom.RoomInstanceId || !string.Equals(currentRoom.PhotonRoomId, targetRoom.PhotonRoomId, StringComparison.OrdinalIgnoreCase)); /// Mutate the response so it represents a fresh invite-only /// match: forks the PhotonRoomId with a per-(player, nonce) suffix /// so randos can't matchmake into it, sets isPrivate=true, and /// registers the instance in /// so invitees coming in via /goto/instance/{id} can find this /// exact Photon match. private async Task ApplyPrivateInstanceAsync(MatchmakingResponseDto resp, long subRoomId) { if (this.CurrentPlayerId() is not long ownerId) { // Anonymous private-instance creation makes no sense — the // owner is the keying signal for the photon room name and // for invite resolution. Fall through with isPrivate flag // only; the watch won't be able to invite anyone but at // least won't mismatch the requested form value. resp.RoomInstance.IsPrivate = true; return; } var inst = await privateInstances.RegisterAsync( roomId: resp.RoomInstance.RoomId, subRoomId: subRoomId, ownerPlayerId: ownerId, baseName: resp.RoomInstance.Name.ToLowerInvariant(), location: resp.RoomInstance.Location, dataBlob: resp.RoomInstance.DataBlob, photonRegion: resp.RoomInstance.PhotonRegionId, maxCapacity: resp.RoomInstance.MaxCapacity); resp.RoomInstance.RoomInstanceId = inst.InstanceId; resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId; resp.RoomInstance.IsPrivate = true; logger.LogInformation( "[goto-private] room={Room} sub={Sub} owner={Owner} → instId={Id} photon={Photon}", resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId); } /// Apply the "make a new instance" mutation to a freshly /// built matchmaking response based on the watch's JoinMode (and the /// legacy CreatePrivateInstance flag). Does nothing for ordinary /// PublicMatchmaking joins (JoinMode=0) — those keep the shared, /// deterministic instance that lets randos matchmake together. /// • JoinMode=2 / CreatePrivateInstance → fresh invite-gated /// instance (registered + owned, isPrivate=true). /// • JoinMode=1 → fresh PUBLIC instance (unique id+photon room, /// isPrivate=false, not persisted — lives only while populated). /// Both forks change roomInstanceId AND photonRoomId, which is what /// the watch's transition state machine needs to actually leave the /// current match instead of softlocking on "Going to <room>". private async Task ApplyNewInstanceAsync( MatchmakingResponseDto resp, long subRoomId, bool createPrivateInstance, int joinMode) { // Private ROOMS (Accessibility=0) allow exactly ONE instance per // sub-room: whatever the watch asked for (plain matchmake, "new // instance", "private instance"), everyone funnels into the same // deterministic invite-gated match. Without this, JoinMode=1/2 // nonce-forks fragment the owner's private room into parallel // instances that can never see each other. Dorms are already // stamped with their own deterministic private instance in // BuildResponseAsync — leave them untouched. var room = await db.Rooms.AsNoTracking() .Where(r => r.Id == resp.RoomInstance.RoomId) .Select(r => new { r.Accessibility, r.IsDormRoom, r.CreatorPlayerId }) .FirstOrDefaultAsync(); if (room is { Accessibility: 0, IsDormRoom: false }) { // Owner of the instance = the ROOM's creator (not the caller): // invite authority and rejoin rights belong to the room owner // even when an invitee's join is what materialised the row. var inst = await privateInstances.EnsureForPrivateRoomAsync( ownerPlayerId: room.CreatorPlayerId, roomId: resp.RoomInstance.RoomId, subRoomId: subRoomId, baseName: resp.RoomInstance.Name.ToLowerInvariant(), location: resp.RoomInstance.Location, dataBlob: resp.RoomInstance.DataBlob, photonRegion: resp.RoomInstance.PhotonRegionId, maxCapacity: resp.RoomInstance.MaxCapacity, photonRoomId: resp.RoomInstance.PhotonRoomId); // With everyone sharing ONE instance, access control matters // more than it did when strangers forked into parallel copies: // an uninvited join would land IN the owner's session. Allow // the creator, accepted role holders (co-owner/mod/host), and // instance invitees; everyone else gets the same "room not // found" the browse filters imply (private rooms are invisible, // so admitting the room exists would leak more than a 4 does). var caller = this.CurrentPlayerId(); var allowed = caller is long callerPid && (callerPid == room.CreatorPlayerId || await privateInstances.CanJoinAsync(inst.InstanceId, callerPid) || await db.RoomRoles.AsNoTracking().AnyAsync(rr => rr.RoomId == resp.RoomInstance.RoomId && rr.PlayerId == callerPid && rr.Accepted)); if (!allowed) { logger.LogWarning( "[goto-privateroom] denied player={Player} room={Room} sub={Sub} — not creator/role/invitee", caller, resp.RoomInstance.RoomId, subRoomId); resp.ErrorCode = 4; // RoomDoesNotExist — don't leak private rooms resp.RoomInstance = null; return; } resp.RoomInstance.RoomInstanceId = inst.InstanceId; resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId; resp.RoomInstance.IsPrivate = true; logger.LogInformation( "[goto-privateroom] room={Room} sub={Sub} owner={Owner} joinMode={Mode} → single instance instId={Id} photon={Photon}", resp.RoomInstance.RoomId, subRoomId, room.CreatorPlayerId, joinMode, inst.InstanceId, inst.PhotonRoomId); return; } if (createPrivateInstance || joinMode == 2 /* PrivateNewInstance */) { await ApplyPrivateInstanceAsync(resp, subRoomId); } else if (joinMode == 1 /* PublicNewInstance */) { ApplyNewPublicInstance(resp); } } /// Fork a fresh PUBLIC instance of the room the watch just /// asked for (JoinMode=PublicNewInstance). Unlike a private instance /// this isn't persisted or invite-gated — it's discoverable through /// live presence (friends joining /// via /goto/player) and evaporates once empty. The id is packed the /// same way as /// (nonce in the low 24 bits, roomId/subRoomId in the high bits, kept /// inside int63 for LitJson) so it can't collide with the shared /// roomId*100000 instance the player is forking away from. The photon /// room id gets a _new{nonce} suffix on the existing base so it /// differs from the shared lobby's room. private void ApplyNewPublicInstance(MatchmakingResponseDto resp) { var nonce = Random.Shared.Next(0x10000, 0xFFFFFF); var instanceId = (long)(((ulong)(uint)nonce & 0xFFFFFFul) ^ ((ulong)resp.RoomInstance.RoomId << 24) ^ ((ulong)resp.RoomInstance.SubRoomId << 40)); if (instanceId < 0) instanceId = -instanceId; resp.RoomInstance.RoomInstanceId = instanceId; resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_new{nonce:x6}"; resp.RoomInstance.IsPrivate = false; logger.LogInformation( "[goto-newpublic] room={Room} sub={Sub} → instId={Id} photon={Photon}", resp.RoomInstance.RoomId, resp.RoomInstance.SubRoomId, instanceId, resp.RoomInstance.PhotonRoomId); } /// Upsert the (room, player) /// row for this join. New rows bump /// (unique-visitor metric); /// every join — new or repeat — bumps /// + the per-player counter. private async Task RecordVisitAsync(long playerId, long roomId) { var visit = await db.RoomVisits .FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId); var isFirst = visit is null; if (isFirst) { visit = new DorkNet.Server.Data.Entities.RoomVisitEntity { RoomId = roomId, PlayerId = playerId, FirstVisitAt = DateTime.UtcNow, }; db.RoomVisits.Add(visit); } visit!.LastVisitAt = DateTime.UtcNow; visit.VisitCount += 1; // Counter bumps go through ExecuteUpdate to avoid re-reading // the room — atomic increment under concurrent /goto calls. // Also bumps HotScore so the "Hot" tab self-reorders as // players actually use rooms. Weight: each first-time visit // adds 2.0, each repeat adds 0.25 (revisits matter less than // new players). Cheers also feed in via PlayerStateController // / MissingEndpointsController by the same factor. if (isFirst) { await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) .SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1) .SetProperty(r => r.HotScore, r => r.HotScore + 2.0)); } else { await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) .SetProperty(r => r.HotScore, r => r.HotScore + 0.25)); } await db.SaveChangesAsync(); } private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) { // Honors the global-friends toggle (every other player is a "friend" // when it is on). Goes through the shared fan-out so the frame // carries the player's real status visibility and the same // online/room state every other presence transition reports. logger.LogInformation( "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}", playerId, room.RoomId, room.Name); await presenceFanout.PublishCurrentAsync(playerId); } private async Task BuildResponseAsync(string roomName) { // Try to resolve from the Rooms DB first — that gives us the real // RoomId (so subsequent /api/rooms/v4/details/{id} hits the same // entity) and the canonical LocationReplicationId. Fall back to the // baked-in DormRoom mapping for unknown names. long roomId; string locationId; var maxCapacity = 8; // admin-adjustable per-room cap; 8 = default/fallback if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) { // Each player has their own dorm row (created at signup, // lazy-created here for legacy accounts that pre-date the // per-player dorm work). Resolve to the caller's personal // dorm so /api/rooms/v4/details/{id} hits a real Rooms.id // and storage.rec.net/upload's room lookup succeeds. if (this.CurrentPlayerId() is long callerPid) { var personal = await rooms.EnsurePersonalDormAsync(callerPid); roomId = personal.Id; locationId = personal.LocationReplicationId; maxCapacity = personal.MaxCapacity; } else { // Anonymous /goto/room/DormRoom shouldn't happen in the // 2020 watch (the watch always authenticates first), but // fall back to the canonical scene id for safety. roomId = 1; locationId = "76d98498-60a1-430c-ab76-b54a29b7a163"; } } else { var room = await rooms.GetByNameAsync(roomName); if (room is not null) { roomId = room.Id; locationId = room.LocationReplicationId; maxCapacity = room.MaxCapacity; } else { roomId = MapRoomNameToId(roomName); locationId = MapRoomNameToReplicationId(roomName); } } var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); // DormRoom is per-player (each account has its own dorm); Rec Center // and other public rooms are shared by name. Including the player's // id in the dorm room name avoids the case where rebuilding the DB // gives a fresh accountId but Photon still considers the old // "recroom_dormroom_1" room locked from the previous account's // session — manifested as a successful /goto but no actual scene // load (black screen). var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); var dormOwnerId = this.CurrentPlayerId(); var dormInstanceName = isDorm && dormOwnerId is long ownerForDisplay ? await BuildDormInstanceNameAsync(ownerForDisplay) : roomName; var photonRoom = (isDorm && dormOwnerId is long ownerForName) ? $"^dormroom_p{ownerForName}" : $"^{roomName.ToLowerInvariant()}_{roomId}"; var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId); var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0); var resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = new RoomInstanceDto { // Deterministic: same name → same instance id, every time. RoomInstanceId = roomId * 100_000L, RoomId = roomId, SubRoomId = 0, // location is a STRING replicationId, parsed via // RoomSceneLocations.TryGetRoomSceneLocationById (RVA 0x10F57D0). // The value is matched against AGRoomRuntimeConfig.Room.ReplicationId // entries baked into resources.assets. Sending an int (or wrong // string) makes RoomInstance.Deserialize log "RecNet game session // contains unknown scene location ID" and the scene never loads. Location = locationId, // photonRegionId is a STRING that maps to CloudRegionCode enum // (eu, us, asia, jp, none, au, usw, sa, cae, kr, in, ru, rue). // Lowercase enum names — that's the wire format Photon uses too. PhotonRegionId = photonRegion, PhotonRoomId = photonRoom, Name = dormInstanceName, MaxCapacity = maxCapacity, IsFull = false, IsPrivate = false, IsInProgress = true, DataBlob = dataBlob, SubRoomUnityAssetId = subRoomUnityAssetId, }, }; logger.LogInformation( "[goto] room instance room={RoomId} name={RoomName} player={PlayerId} dataBlob={DataBlob}", roomId, roomName, dormOwnerId, dataBlob); // Dorms are always private — the per-player photon-room name // (^dormroom_p{owner}) prevents matchmaking, but without // IsPrivate=true the /goto/player path mirrors the room // verbatim and lets uninvited friends drop straight in. Stamp // the response with a deterministic-id PrivateInstance owned // by the dorm's owner so CanJoinAsync gates non-owners. if (isDorm && dormOwnerId is long ownerPid) { var dorm = await privateInstances.EnsureForDormAsync( ownerPlayerId: ownerPid, roomId: resp.RoomInstance.RoomId, subRoomId: 0, baseName: dormInstanceName, location: resp.RoomInstance.Location, dataBlob: resp.RoomInstance.DataBlob, photonRegion: resp.RoomInstance.PhotonRegionId, maxCapacity: resp.RoomInstance.MaxCapacity, photonRoomId: resp.RoomInstance.PhotonRoomId); resp.RoomInstance.RoomInstanceId = dorm.InstanceId; resp.RoomInstance.IsPrivate = true; } return resp; } private async Task BuildDormInstanceNameAsync(long ownerPlayerId) { var player = await db.Players.AsNoTracking() .Where(p => p.Id == ownerPlayerId) .Select(p => new { p.DisplayName, p.Username }) .FirstOrDefaultAsync(); var name = !string.IsNullOrWhiteSpace(player?.DisplayName) ? player!.DisplayName : !string.IsNullOrWhiteSpace(player?.Username) ? player!.Username : $"Player{ownerPlayerId}"; var cleaned = new string(name .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_') .ToArray()).Trim('_'); return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm"; } private async Task ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId) { var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return string.Empty; if (isDorm && dormOwnerId is long ownerId) { var dormBlob = await rooms.ResolveDormDataBlobNameAsync(ownerId, room.Id); if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; } if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; if (RoomService.IsBakedOriginalRoom(room)) return string.Empty; return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } private async Task ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId) { return await db.RoomScenes.AsNoTracking() .Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId) .Select(s => s.StudioUnityAssetId) .FirstOrDefaultAsync() ?? string.Empty; } /// /// Stable id-per-room-name map for well-known rooms; unknown names get /// a hash-based id so each room name routes to its own roomId space. /// private static long MapRoomNameToId(string name) => name.ToLowerInvariant() switch { "dormroom" => 1, "rec_center" or "reccenter" => 2, "lobby" => 3, _ => Math.Abs(name.GetHashCode()) % 100_000 + 1000, }; /// /// Maps a friendly room name to the RoomScene.RoomSceneLocationId GUID /// baked into resources.assets's AGRoomRuntimeConfig.Rooms[].Scenes[0]. /// This is the foreign key that links a Room's "Home" scene to an /// AGRoomRuntimeConfig.Locations entry — and is what /// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0) matches /// against Location.ReplicationId. /// /// The asset stores each Room as [Name] [Room.ReplicationId] /// [Description] [Scene.Name="Home"] [Scene.ReplicationId] /// [Scene.RoomSceneLocationId] — so the THIRD GUID after the room name /// is the one we want. Earlier I sent the FIRST (Room.ReplicationId) /// which is a different namespace entirely; the deserializer logged /// "unknown scene location ID" because no Location had that GUID. /// private static string MapRoomNameToReplicationId(string name) => name.ToLowerInvariant() switch { "dormroom" => "76d98498-60a1-430c-ab76-b54a29b7a163", "rec_center" or "reccenter" => "cbad71af-0831-44d8-b8ef-69edafa841f6", "3dcharades" or "charades" => "4078dfed-24bb-4db7-863f-578ba48d726b", "discgolflake" or "lake" => "f6f7256c-e438-4299-b99e-d20bef8cf7e0", "discgolfpropulsion" or "propulsion" => "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", "dodgeball" => "3d474b26-26f7-45e9-9a36-9b02847d5e6f", "paddleball" => "d89f74fa-d51e-477a-a425-025a891dd499", "river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", "homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", "quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", "clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", "spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", "drive-in" or "drivein" => "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", "soccer" => "7e01cfe0-820a-406f-b1b3-0a5bf575235c", "lasertag" => "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", "cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038", "recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f", "recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11", "recrally" => "56193568-9ae0-498c-8a77-4df79dec91f5", _ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom }; } public class MatchmakingResponseDto { [JsonPropertyName("errorCode")] public int ErrorCode { get; set; } [JsonPropertyName("roomInstance")] public RoomInstanceDto? RoomInstance { get; set; } = new(); } /// /// Mirrors RecNet.RoomInstance — Deserialize at RVA 0x114DAB0. Keys /// verified by disassembly. JSON casing is camelCase throughout. /// public class RoomInstanceDto { [JsonPropertyName("roomInstanceId")] public long RoomInstanceId { get; set; } [JsonPropertyName("roomId")] public long RoomId { get; set; } /// The sub-room's /// (0 = the room's first sub-room). Server code reads and writes this; /// the wire carries . [JsonIgnore] public long SubRoomId { get; set; } /// The 2023 client's SubRoomId: OrderIndex + 1 (see /// ). It must equal the /// SubRooms[].SubRoomId the room details advertise — the client /// compares the two (SubroomModel.get_SubroomIsLocal) and looks the /// current sub-room up by it. Presence round-trips through this property /// too, so stored JSON stays consistent. [JsonPropertyName("subRoomId")] public long WireSubRoomId { get => Services.RoomSceneIds.ToWire(SubRoomId); set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value); } // STRING RoomSceneLocationId GUID — foreign-keys to // AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via // AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default // = DormRoom's home-scene location ID extracted from resources.assets // at offset 0x65107ec. [JsonPropertyName("location")] public string Location { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163"; // STRING enum-name — CloudRegionCode is parsed by name: eu, us, asia, // jp, none, au, usw, sa, cae, kr, in, ru, rue. Lowercase. [JsonPropertyName("photonRegionId")] public string PhotonRegionId { get; set; } = "us"; [JsonPropertyName("photonRoomId")] public string PhotonRoomId { get; set; } = string.Empty; [JsonPropertyName("maxCapacity")] public int MaxCapacity { get; set; } = 8; [JsonPropertyName("isFull")] public bool IsFull { get; set; } = false; [JsonPropertyName("isPrivate")] public bool IsPrivate { get; set; } = false; [JsonPropertyName("isInProgress")] public bool IsInProgress { get; set; } = true; // Optional keys per disassembly (Util.GetKeyOrDefault). [JsonPropertyName("dataBlob")] public string DataBlob { get; set; } = string.Empty; // 2023 RoomLoading.LoadRoomCommand carries this separately from // dataBlob. If it is empty, the loader can deserialize room view data // before the Studio asset bundle has registered its prefabs, producing // "Bad View Data detected. Expected a prefab, but found none". [JsonPropertyName("subRoomUnityAssetId")] public string SubRoomUnityAssetId { get; set; } = string.Empty; [JsonPropertyName("eventId")] public long EventId { get; set; } = 0; // Raw room name (NO leading "^"). Kept clean because server-side code // derives other values from it — most importantly // PrivateInstanceService builds the photonRoomId as "^{baseName}_…" // from this, and MessagesController uses it verbatim as the invite // message's display name (the "just the event name" case, no "^"). [JsonIgnore] public string Name { get; set; } = string.Empty; // Wire "name" field. The 2020.12 watch's PlayerPresence.FriendlyPresence // (EFAJBHGDIDD.JDICJBJBMMI → BaseAccountModel.get_FriendlyPresence) shows // roomInstance.name VERBATIM and its built-in default is the literal // "^DormRoom" — i.e. a player's current room is expected to carry a // leading "^". So we prefix on serialize and strip on deserialize // (PlayerPresenceService round-trips this DTO through Redis), keeping the // C# Name raw. Empty stays empty so the watch falls back to its own // "^DormRoom" default. Invite MESSAGES keep the plain name — that's a // separate field built from the raw Name in MessagesController. [JsonPropertyName("name")] public string NameWire { get => string.IsNullOrEmpty(Name) || Name.StartsWith('^') ? Name : "^" + Name; set => Name = !string.IsNullOrEmpty(value) && value.StartsWith('^') ? value[1..] : value; } // 2020.12 added these keys to RoomInstance.Deserialize. Verified via // Cpp2IL ISIL on CIIBGMBOFEI.PPGFHEDFBEA (the obfuscated RoomInstance // class). Missing → "Malformed Response" in matchmaking. [JsonPropertyName("roomInstanceType")] public int RoomInstanceType { get; set; } = 0; [JsonPropertyName("clubId")] public long ClubId { get; set; } = 0; [JsonPropertyName("roomCode")] public string RoomCode { get; set; } = string.Empty; // NOTE: PascalCase 'E'. The 2020.12 ISIL literally loads "EncryptVoiceChat" // (not "encryptVoiceChat"). LitJson is case-sensitive. [JsonPropertyName("EncryptVoiceChat")] public bool EncryptVoiceChat { get; set; } = false; /// /// Returns a copy of with photonRoomId /// and dataBlob blanked out — safe to send to OTHER players /// (friend-list presence, push updates). The watch only needs /// roomId / name / isPrivate to render the /// friend-list row; it joins via /goto/player/{id} or /// /goto/instance/{id}, both of which enforce /// . /// /// Without this redaction, anyone could read another player's /// presence and use the leaked photonRoomId to JoinByName /// in Photon directly, bypassing our invite-list check. /// public static RoomInstanceDto? Redact(RoomInstanceDto? room) { if (room is null) return null; return new RoomInstanceDto { RoomInstanceId = room.RoomInstanceId, RoomId = room.RoomId, SubRoomId = room.SubRoomId, Location = room.Location, PhotonRegionId = room.PhotonRegionId, PhotonRoomId = string.Empty, MaxCapacity = room.MaxCapacity, IsFull = room.IsFull, IsPrivate = room.IsPrivate, IsInProgress = room.IsInProgress, DataBlob = string.Empty, SubRoomUnityAssetId = room.SubRoomUnityAssetId, EventId = room.EventId, Name = room.Name, }; } } /// Rate limit for "wants to join" messages: one per (requester, /// owner, room instance) per . Later retries and other /// instances still go through. Per replica, which is enough to stop a /// retry loop from flooding an inbox. internal static class JoinRequestCooldown { public static readonly TimeSpan Window = TimeSpan.FromMinutes(5); private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new(); /// True when no request for this key was sent within the window /// (and records this one); false while it is cooling down. public static bool TryStart(long requester, long owner, long roomInstanceId) { var now = DateTime.UtcNow; if (Last.Count > 10_000) foreach (var (key, at) in Last) if (now - at > Window) Last.TryRemove(key, out _); var k = (requester, owner, roomInstanceId); while (true) { if (!Last.TryGetValue(k, out var prev)) { if (Last.TryAdd(k, now)) return true; continue; } if (now - prev < Window) return false; if (Last.TryUpdate(k, now, prev)) return true; } } }