using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using DorkNet.Models.GameSessions; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Match; /// /// match.rec.net — dedicated matchmaking service used by later 2020 builds. /// Delegates to the Postgres-backed GameSessionService. /// [ApiController] [Authorize] public class MatchController( IConfiguration config, GameSessionService sessionService, PlayerPresenceService presence, PrivateInstanceService privateInstances, JoinTimeoutService joinTimeouts, PresenceBroadcastService presenceFanout, RoomService rooms, DorkNetDbContext db, ILogger logger) : ControllerBase { /// POST match.*/roominstance/{id}/reportjoinresult — /// the watch fires this AFTER its Photon connect+join attempt, /// reporting how it went via RecNet.Matchmaking+GameJoinResult /// (Matchmaking.cs:121): /// 0 = Success, 1 = FailedToConnectToRegion, /// 2 = FailedToConnectToRoom, 3 = FailedToSpawnPlayer. /// Body is form-urlencoded result=N. Watch ignores the /// response body and just needs 2xx (Matchmaking.txt:6668-6699), so /// we mostly use this for visibility into Photon-side issues — /// without it, all we see in the catch-all is an opaque "200, empty /// reply" and no clue why a player crashed back to dorm. Failure /// reports also clear the pending /goto room so the next matchmaking /// attempt starts from a clean presence state. [HttpPost("/roominstance/{instanceId:long}/reportjoinresult")] public async Task ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result) { var code = result ?? -1; var label = code switch { 0 => "Success", 1 => "FailedToConnectToRegion", 2 => "FailedToConnectToRoom", 3 => "FailedToSpawnPlayer", _ => $"Unknown({code})", }; var pid = this.RequireCurrentPlayerId(); if (code == 0) { var completed = joinTimeouts.MarkCompleted(pid, instanceId, label); if (completed.HasValue) { presence.SetRoom(pid, completed.Value.TargetRoom); presence.MarkActive(pid); presence.EndBootDormGrace(pid); logger.LogInformation( "[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}", pid, completed.Value.TargetRoom.RoomId, completed.Value.TargetRoom.Name, completed.Value.TargetRoom.RoomInstanceId, completed.Value.DeferPresenceCommit); // A deferred join (matchmake/dorm, private-instance hop) // commits HERE, not at matchmake time, so this is the only // point friends can learn about the move. Immediate commits // were already announced by /goto. if (completed.Value.DeferPresenceCommit) await presenceFanout.PublishCurrentAsync(pid); } else { presence.MarkActive(pid); presence.TouchRoom(pid); presence.EndBootDormGrace(pid); } logger.LogInformation( "[joinresult] player={Player} instance={Instance} OK ({Label})", pid, instanceId, label); } else { await joinTimeouts.MarkFailedAsync(pid, instanceId, label); logger.LogWarning( "[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label})", pid, instanceId, code, label); } return Ok(); } /// POST matchmake/none — "leave room". The 2023 client's /// only caller is TryGoToOfflineRoom /// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already /// switched its own cached presence to the local offline instance /// (RoomInstanceId -1) before posting this, and it discards the reply /// (<TryGoToOfflineRoom>b__119_0 returns a constant). /// /// So the player is now online in no room, and that is what presence must /// say. This used to re-commit the previous room (or the dorm) instead: /// the next heartbeat then answered with a room while the client held the /// offline instance, the client logged "presence heartbeat response /// indicates local presence is out-of-sync", cancelled its offline load /// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the /// server's room — and friends kept seeing the room the player had left. /// /// A join still pending is abandoned too: the client only goes offline /// after giving up on it (fallback chain after a Photon timeout), and /// echoing its target from the heartbeat would pull the player back. [HttpPost("/matchmake/none")] public async Task MatchmakeNone() { var pid = this.CurrentPlayerId() ?? 0; if (pid == 0) return Unauthorized(); var previous = presence.GetRoom(pid); var pending = joinTimeouts.GetPending(pid); joinTimeouts.Cancel(pid, "matchmake-none"); presence.SetNoRoom(pid); presence.MarkActive(pid); presence.EndBootDormGrace(pid); logger.LogInformation( "[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room", pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId); await presenceFanout.PublishCurrentAsync(pid); // The client ignores this body; keep a well-formed instance in it so // the shared MatchmakingResponse reader never sees a null object. return Ok(new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid), }); } [HttpGet("/photon_access_token")] public IActionResult PhotonAccessToken() { var pid = this.RequireCurrentPlayerId(); var appId = config["Photon:AppId"] ?? string.Empty; var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty; var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); var currentRoom = presence.GetRoom(pid); var permissions = BuildPhotonPermissionSeed(); logger.LogInformation( "[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}", pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length); return Ok(new Dictionary { // 2023 deserializes this endpoint as ALIIIAGAPND before room // permissions initialization. If Permissions is missing/empty, // OMJKHJLFOCO falls into the persisted-role migration path that // currently NREs during first spawn. ["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L, ["PhotonAccessToken"] = string.Empty, ["Permissions"] = permissions, // Existing custom-auth/app-id shape kept for Photon auth callers. ["ResultCode"] = 1, ["Message"] = string.Empty, ["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture), ["PhotonAppId"] = appId, ["PhotonVoiceAppId"] = voiceAppId, ["AppId"] = appId, ["VoiceAppId"] = voiceAppId, ["CloudRegion"] = region, ["Region"] = region, ["Token"] = string.Empty, }); } private static object[] BuildPhotonPermissionSeed() => [ BuildPhotonPermission(13, 30), // Can Use Maker Pen BuildPhotonPermission(20, 30), // Can Spawn Inventions BuildPhotonPermission(21, 30), // Can Spawn Consumables BuildPhotonPermission(22, 30), // Can Use Room Reset Button BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle BuildPhotonPermission(13, 255), // Creator alias BuildPhotonPermission(20, 255), BuildPhotonPermission(21, 255), BuildPhotonPermission(22, 255), BuildPhotonPermission(23, 255), ]; private static object BuildPhotonPermission(int permission, int role) => new { Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture), Role = role, Override = true, Type = 0, Value = "true", }; [HttpPost("/matchmake/dorm")] public async Task MatchmakeDorm() { var pid = this.CurrentPlayerId() ?? 0; if (pid == 0) return Unauthorized(); var room = await BuildDormRoomInstanceAsync(pid); joinTimeouts.MarkPending(pid, room, deferPresenceCommit: true); presence.MarkActive(pid); await RecordVisitAsync(pid, room.RoomId); logger.LogInformation( "[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon} deferredPresence=True", pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId); return Ok(new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = room, }); } private async Task BuildDormRoomInstanceAsync(long playerId) { var personal = await rooms.EnsurePersonalDormAsync(playerId); var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id); if (string.IsNullOrWhiteSpace(dataBlob)) dataBlob = personal.CurrentDataBlobName; var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); var photonRoomId = $"^dormroom_p{playerId}"; var dormInstanceName = await BuildDormInstanceNameAsync(playerId); var dorm = await privateInstances.EnsureForDormAsync( ownerPlayerId: playerId, roomId: personal.Id, subRoomId: 0, baseName: dormInstanceName, location: personal.LocationReplicationId, dataBlob: dataBlob ?? string.Empty, photonRegion: photonRegion, maxCapacity: personal.MaxCapacity, photonRoomId: photonRoomId); return new RoomInstanceDto { RoomInstanceId = dorm.InstanceId, RoomId = personal.Id, SubRoomId = 0, Location = personal.LocationReplicationId, PhotonRegionId = photonRegion, PhotonRoomId = photonRoomId, Name = dormInstanceName, MaxCapacity = personal.MaxCapacity, IsFull = false, IsPrivate = true, IsInProgress = true, DataBlob = dataBlob ?? string.Empty, EventId = 0, }; } 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 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 RoomVisitEntity { RoomId = roomId, PlayerId = playerId, FirstVisitAt = DateTime.UtcNow, }; db.RoomVisits.Add(visit); } visit!.LastVisitAt = DateTime.UtcNow; visit.VisitCount += 1; 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(); } /// PUT /roominstance/{id}/inprogress — fired by /// RecNet.Matchmaking.SetGameInProgress(bool inProgress) /// (Matchmaking.cs:333). Body is form-urlencoded /// inProgress=true|false. The watch uses this to mark a /// match as "in progress" so newcomers are sent to a fresh instance /// instead of joining a game already underway (e.g. paintball /// during a round). Our matchmaker doesn't gate on this yet — log /// + 200 to take it off the catch-all. [HttpPut("/roominstance/{instanceId:long}/inprogress")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public IActionResult SetInProgress( long instanceId, [FromForm(Name = "inProgress")] bool? inProgress) { var pid = this.RequireCurrentPlayerId(); logger.LogInformation( "[set-inprogress] player={Player} instance={Instance} inProgress={Flag}", pid, instanceId, inProgress); return Ok(); } /// POST /roominstance/{id}/markprivate — promote /// the caller's current public instance to an invite-gated /// PrivateInstance row. The watch only needs a RecNet result /// wrapper, but the server persists the privacy state so later /// invite joins are enforced by . // The 2023-03-21 client sends PUT (verb constant 3 at // RecNet/Matchmaking.txt:15483 and :15774) with an empty body. POST-only // returned 405, so a host could never close a public instance to private. [HttpPut("/roominstance/{instanceId:long}/markprivate")] [HttpPost("/roominstance/{instanceId:long}/markprivate")] public async Task MarkPrivate(long instanceId) { var pid = this.RequireCurrentPlayerId(); var existing = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId); if (existing is not null) { if (existing.OwnerPlayerId != pid) return Forbid(); return Ok(new { Success = true, Error = string.Empty }); } var current = presence.GetRoom(pid); if (current is null || current.RoomInstanceId != instanceId) return NotFound(new { Success = false, Error = "room instance not found for current player" }); db.PrivateInstances.Add(new PrivateInstanceEntity { Id = instanceId, RoomId = current.RoomId, SubRoomId = current.SubRoomId, OwnerPlayerId = pid, PhotonRoomId = current.PhotonRoomId, Location = current.Location, DataBlob = current.DataBlob, PhotonRegion = current.PhotonRegionId, MaxCapacity = current.MaxCapacity, Name = current.Name, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); logger.LogInformation("[mark-private] player={Player} instance={Instance}", pid, instanceId); return Ok(new { Success = true, Error = string.Empty }); } /// GET/POST/PUT /roominstance/{id}/roomcode — the room code /// shown by the watch, as a raw JSON string. /// /// GET returns the instance's code. The 2023-03-21 client also PUTs here to /// SET a custom code, sending form fields roomCode and /// forceChange (verb 3 at RecNet/Matchmaking.txt:16234). PUT was not /// registered, so custom room codes 405'd; and because the handler always /// regenerated the deterministic code, a submitted one would have been /// discarded anyway. A submitted code is now validated, claimed and /// persisted, and the accepted code is echoed back. /// /// forceChange lets the host take a code already claimed by one of /// their own instances; it never steals another player's code. [HttpGet("/roominstance/{instanceId:long}/roomcode")] [HttpPost("/roominstance/{instanceId:long}/roomcode")] [HttpPut("/roominstance/{instanceId:long}/roomcode")] public async Task RoomCode( long instanceId, [FromForm(Name = "roomCode")] string? roomCode, [FromForm(Name = "forceChange")] bool? forceChange) { // Same normalisation the join path applies (GoToController.GoToCode → // RoomCodeService.Normalize), so a code typed with dashes/lowercase on // the watch still matches what we persist. var normalized = RoomCodeService.Normalize( roomCode ?? Request.Query["roomCode"].FirstOrDefault()); if (normalized.Length > 0) { var pid = this.RequireCurrentPlayerId(); var clash = await db.PrivateInstances .Where(p => p.RoomCode == normalized && p.Id != instanceId) .Select(p => new { p.Id, p.OwnerPlayerId }) .FirstOrDefaultAsync(); var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId); // Only the instance's owner may set its code (same guard as // MarkPrivate), otherwise any player could plant or relocate a // code onto someone else's instance. forceChange only lets a host // reclaim a code from one of their OWN instances; another // player's code is never stolen. On refusal the client just // re-reads whatever code we echo back, so we fall through to the // read path instead of erroring. var blocked = row is null || row.OwnerPlayerId != pid || (clash is not null && (clash.OwnerPlayerId != pid || forceChange != true)); if (!blocked && row is not null) { if (clash is not null) { // Release the caller's older claim first, as its own // statement, so the two rows never hold the code at once. await db.PrivateInstances .Where(p => p.Id == clash.Id) .ExecuteUpdateAsync(s => s.SetProperty(p => p.RoomCode, string.Empty)); } row.RoomCode = normalized; await db.SaveChangesAsync(); logger.LogInformation( "[roomcode] player={Player} instance={Instance} code={Code} force={Force}", pid, instanceId, normalized, forceChange == true); return Content(JsonSerializer.Serialize(normalized), "application/json"); } logger.LogWarning( "[roomcode] player={Player} instance={Instance} refused code={Code} taken={Taken} known={Known}", pid, instanceId, normalized, clash is not null, row is not null); } var stored = await db.PrivateInstances .Where(p => p.Id == instanceId) .Select(p => p.RoomCode) .FirstOrDefaultAsync(); var code = string.IsNullOrEmpty(stored) ? RoomCodeService.Generate(instanceId) : stored; return Content(JsonSerializer.Serialize(code), "application/json"); } [HttpPost("/v1/find")] public async Task> Find([FromBody] JoinRandomGameSessionRequest req) { var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId); return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session }); } [HttpPost("/v1/join/{sessionId:long}")] public async Task> Join(long sessionId) { var session = await sessionService.GetByIdAsync(sessionId); if (session is null) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound }); if (session.IsFull) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full }); return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session }); } [HttpPost("/v1/leave/{sessionId:long}")] public async Task Leave(long sessionId) { await sessionService.PlayerLeftAsync(sessionId); return Ok(); } [HttpGet("/v1/session/{sessionId:long}")] public async Task> GetSession(long sessionId) { var session = await sessionService.GetByIdAsync(sessionId); if (session is null) return NotFound(); return Ok(session); } /// GET match.*/room/{id}/instances — the /// "RoomInstanceBrowser" the watch shows when you tap a room tile. /// Returns the currently-active instances so the player can see /// which one their friends are in (and join it) instead of being /// matchmade into a fresh empty room. Verified URL at /// Matchmaking.txt:6286; response is a /// List<SimpleRoomInstance> per /// SimpleRoomInstance.cs: RoomInstanceId/RoomId/SubRoomId, /// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount, /// HasModPresent, HashedInstanceId. /// /// We derive instances from PlayerPresenceService's per-player /// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId /// is one instance, with the players currently presenced there. /// PrivateInstances are surfaced as well so the inviter and /// invitee see the same room in the browser. [HttpGet("/room/{roomId:long}/instances")] [AllowAnonymous] public async Task RoomInstances(long roomId) { var presenced = presence.EnumerateActiveInstances(roomId).ToList(); // Only surface the caller's OWN private instances — we never // leak someone else's PhotonRoomId into the browser. The Photon // join check still requires CanJoinAsync, but redacting at the // list level matches the existing RoomInstanceDto.Redact policy. var pid = this.CurrentPlayerId() ?? 0; var privates = pid == 0 ? new List() : await db.PrivateInstances .Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid) .ToListAsync(); // Merge: presence rows are authoritative (player currently // there); add any owned private instance the inviter created // but nobody's joined yet so the inviter can still see it. var byKey = new Dictionary(StringComparer.Ordinal); foreach (var (room, playerIds) in presenced) { if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; byKey[room.PhotonRoomId] = new SimpleInstance( RoomInstanceId: room.RoomInstanceId, RoomId: room.RoomId, SubRoomId: room.SubRoomId, IsFull: false, CreatedAt: DateTime.UtcNow, PlayerIds: playerIds.Select(p => (int)p).ToList(), SubroomName: room.Name ?? string.Empty, PlayerCount: playerIds.Count, HasModPresent: false, HashedInstanceId: room.PhotonRoomId); } foreach (var p in privates) { if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue; byKey[p.PhotonRoomId] = new SimpleInstance( RoomInstanceId: p.Id, RoomId: p.RoomId, SubRoomId: p.SubRoomId, IsFull: false, CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), PlayerIds: new List(), SubroomName: p.Name, PlayerCount: 0, HasModPresent: false, HashedInstanceId: p.PhotonRoomId); } // Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE // keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt // lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull, // createdAt, playerIds}). SubroomName / PlayerCount / // HasModPresent / HashedInstanceId are populated locally on the // watch (PlayerIds.Count, local hash, etc.) — not read from // JSON, so we don't send them. Earlier PascalCase response made // every field default to 0/null on the watch, breaking the // browser. return Ok(byKey.Values.Select(i => new { roomInstanceId = i.RoomInstanceId, roomId = i.RoomId, subRoomId = i.SubRoomId, isFull = i.IsFull, createdAt = i.CreatedAt, playerIds = i.PlayerIds.ToArray(), })); } private sealed record SimpleInstance( long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull, DateTime CreatedAt, List PlayerIds, string SubroomName, int PlayerCount, bool HasModPresent, string HashedInstanceId); /// DELETE match.*/invite/{id} — Matchmaking.cs /// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method /// enum 4 = DELETE). The id is a MessageEntity row id (V2 game and /// party invites are stored as Type 6/7 messages). Mark it read so the inviter's /// inbox stops nagging, and remove the invitee from the private /// instance's allow-list so they can't change their mind without /// a fresh invite. [HttpDelete("/invite/{messageId:long}")] public async Task DeclineInvite(long messageId) { var pid = this.RequireCurrentPlayerId(); var msg = await db.Messages .FirstOrDefaultAsync(m => m.Id == messageId && m.RecipientPlayerId == pid); if (msg is null) return NotFound(); if (msg.ReadAt is null) { msg.ReadAt = DateTime.UtcNow; } // Current invite messages carry the bare roomInstanceId in // Data/Body. Older local DBs may still have the legacy // "Invite to private match (instance N)" text, so accept both. if (TryParseRoomInstanceId(msg.Body, out var instId)) { await privateInstances.RevokeInviteAsync(instId, pid); } await db.SaveChangesAsync(); logger.LogInformation("[invite] declined messageId={Id} by player={Player}", messageId, pid); return Ok(); } // Same three-shape parser as GoToController.TryParseRoomInstanceId // (see that file for the rationale): JSON for current Type-6/7 // invites, bare int for the pre-fix shape, "Invite to private match // (instance N)" text for ancient rows. 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)) { // See GoToController.TryParseRoomInstanceId for the // rationale on accepting both string and number forms. 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); } }