using Mocha2023.Auth; using Mocha2023.Classes; using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses; using Microsoft.AspNetCore.Mvc; using System.Text.Json; using System.Text.RegularExpressions; namespace Mocha2023.Controllers { [ApiController] [Mocha2023.Classes.ApiProtection] [Route("/match")] public class MatchController : ControllerBase { [HttpGet("player")] public IActionResult GetPlayerHeartbeatBulk() { var playerId = AuthStuff.GetPlayerId(Request); if (playerId == null) return Unauthorized(); var ids = new List(); foreach (string? rawValue in Request.Query["id"]) { if (string.IsNullOrWhiteSpace(rawValue)) continue; foreach (string part in rawValue.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (long.TryParse(part, out long id) && id > 0) ids.Add(id); } } PlayerDB.TouchPlayerHeartbeat((long)playerId); return Ok(PlayerDB.GetPlayerHeartbeatsBulk( ids.Distinct().ToList(), viewingPlayerId: playerId.Value)); } [HttpPost("player/login")] public async Task PlayerLogin() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); Heartbeat before = PlayerDB.GetPlayerHeartbeat( id.Value, viewingPlayerId: id.Value); Heartbeat? heartbeat = PlayerDB.ResumePlayerHeartbeat(id.Value); if (heartbeat == null) return Unauthorized(); if (!before.isOnline && heartbeat.roomInstance == null) Sessions.MarkPlayerLeft(id.Value); bool presenceChanged = before.isOnline != heartbeat.isOnline || before.errorCode != heartbeat.errorCode || before.roomInstance?.roomInstanceId != heartbeat.roomInstance?.roomInstanceId; if (presenceChanged) await NotiController.NotifyPlayerPresenceUpdatedAsync(id.Value); Console.WriteLine( $"[PLAYER LOGIN] player={id.Value} " + $"preservedInstance={heartbeat.roomInstance?.roomInstanceId.ToString() ?? "none"} " + $"reconnectSafe=true presenceChanged={presenceChanged.ToString().ToLowerInvariant()}"); return Ok(); } [HttpPost("player/exclusivelogin")] public IActionResult PlayerExclusiveLogin() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); Console.WriteLine( $"[PLAYER EXCLUSIVE LOGIN] player={id.Value} mutation=none"); return Ok(new { errorCode = (int)MatchmakingErrorCode.Success }); } [HttpPost("player/logout")] public async Task PlayerLogout() { var id = AuthStuff.GetPlayerId(Request); if (id != null) { Sessions.MarkPlayerLeft((long)id); PlayerDB.SetPlayerOffline((long)id); await NotiController.NotifyPlayerPresenceUpdatedAsync(id.Value); } return Ok(); } public sealed class NotifyDisconnectRequest { public int type { get; set; } } [HttpPost("player/notifydisconnect")] public async Task NotifyDisconnect() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); int? disconnectType = null; string rawBody = string.Empty; try { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); if (int.TryParse(form["type"], out int formType)) disconnectType = formType; } else { using var reader = new StreamReader(Request.Body); rawBody = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(rawBody)) { try { using var json = JsonDocument.Parse(rawBody); if (json.RootElement.TryGetProperty("type", out var typeElement) && typeElement.TryGetInt32(out int jsonType)) { disconnectType = jsonType; } } catch (JsonException) { if (int.TryParse(rawBody.Trim().Trim('"'), out int rawType)) disconnectType = rawType; } } } } catch (Exception ex) { Console.WriteLine($"[NOTIFY DISCONNECT] Body parse failed: {ex.Message}"); } Console.WriteLine( $"[NOTIFY DISCONNECT] player={id.Value} " + $"type={disconnectType?.ToString() ?? "unknown"} " + $"contentType={Request.ContentType ?? "null"} " + $"rawBody={rawBody}"); var heartbeat = PlayerDB.GetPlayerHeartbeat(id.Value); PlayerDB.TouchPlayerHeartbeat(id.Value); Console.WriteLine( $"[NOTIFY DISCONNECT] player={id.Value} isolated=true " + $"instance={heartbeat.roomInstance?.roomInstanceId.ToString() ?? "none"} " + "mutation=none transient-safe=true"); return Ok(heartbeat); } [HttpPost("player/heartbeat")] public IActionResult GetPlayerHeartbeat() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(PlayerDB.TouchPlayerHeartbeat((long)id)); } [HttpPost("/data/heartbeat")] public IActionResult DataHeartbeat() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(PlayerDB.TouchPlayerHeartbeat((long)id)); } [HttpPost("invite")] [HttpPut("invite")] [HttpPost("player/invite")] [HttpPut("player/invite")] [HttpPost("room/invite")] [HttpPut("room/invite")] [HttpPost("invite/{targetPlayerId:long}")] [HttpPut("invite/{targetPlayerId:long}")] [HttpPost("player/invite/{targetPlayerId:long}")] [HttpPut("player/invite/{targetPlayerId:long}")] [HttpPost("/api/match/invite")] [HttpPut("/api/match/invite")] [HttpPost("/api/match/invite/{targetPlayerId:long}")] [HttpPut("/api/match/invite/{targetPlayerId:long}")] [HttpPost("roominstance/{roomInstanceId:long}/invite")] [HttpPut("roominstance/{roomInstanceId:long}/invite")] [HttpPost("roominstance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [HttpPut("roominstance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [HttpPost("matchmake/instance/{roomInstanceId:long}/invite")] [HttpPut("matchmake/instance/{roomInstanceId:long}/invite")] [HttpPost("matchmake/instance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [HttpPut("matchmake/instance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [HttpPost("/api/match/roominstance/{roomInstanceId:long}/invite")] [HttpPut("/api/match/roominstance/{roomInstanceId:long}/invite")] [HttpPost("/api/match/roominstance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [HttpPut("/api/match/roominstance/{roomInstanceId:long}/invite/{targetPlayerId:long}")] [RequestSizeLimit(64 * 1024)] public async Task InvitePlayer( long? targetPlayerId = null, long? roomInstanceId = null) { long? inviterId = AuthStuff.GetPlayerId(Request); if (inviterId == null) { return Unauthorized(new { success = false, error = "Authentication is required." }); } var inviterPlayer = PlayerDB.Players.FindById(inviterId.Value); if (inviterPlayer?.Player == null) { return NotFound(new { success = false, error = "Inviting player was not found." }); } var inviterHeartbeat = PlayerDB.GetPlayerHeartbeat(inviterId.Value); var inviterInstance = inviterHeartbeat?.roomInstance; if (inviterInstance == null) { return BadRequest(new { success = false, error = "You must be inside a room before inviting someone." }); } if (!Sessions.IsConfirmedParticipant( inviterId.Value, inviterInstance.roomInstanceId)) { Console.WriteLine( $"[MATCH INVITE DENIED] inviter={inviterId.Value} " + $"instance={inviterInstance.roomInstanceId} " + "reason=membership-not-confirmed"); return StatusCode(409, new { success = false, error = "room_instance_membership_not_confirmed", errorMessage = "Finish joining the room before inviting another player." }); } var targetIds = new HashSet(); var claimedRoomInstanceIds = new HashSet(); string rawBody = string.Empty; if (targetPlayerId.HasValue && targetPlayerId.Value > 0) targetIds.Add(targetPlayerId.Value); if (roomInstanceId is > 0) claimedRoomInstanceIds.Add(roomInstanceId.Value); foreach (string key in InviteAccountIdKeys) { foreach (string value in Request.Query[key]) AddInviteIds(value, targetIds); } foreach (string key in RoomInstanceIdKeys) { foreach (string value in Request.Query[key]) AddInviteIds(value, claimedRoomInstanceIds); } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); rawBody = string.Join( "&", form.SelectMany(entry => entry.Value.Select(value => $"{entry.Key}={value}"))); foreach (string key in InviteAccountIdKeys) { foreach (string value in form[key]) AddInviteIds(value, targetIds); } foreach (string key in RoomInstanceIdKeys) { foreach (string value in form[key]) AddInviteIds(value, claimedRoomInstanceIds); } if (targetIds.Count == 0) { foreach (var entry in form) { if (entry.Key.Contains( "id", StringComparison.OrdinalIgnoreCase) && !RoomInstanceIdKeys.Any(key => entry.Key.Equals( key, StringComparison.OrdinalIgnoreCase))) { foreach (string value in entry.Value) AddInviteIds(value, targetIds); } } } } else { using var reader = new StreamReader(Request.Body); rawBody = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(rawBody)) { string cleanedBody = rawBody.Trim().Trim('"'); AddInviteIds(cleanedBody, targetIds); try { using var document = JsonDocument.Parse(rawBody); FindInviteIds( document.RootElement, targetIds); FindRoomInstanceIds( document.RootElement, claimedRoomInstanceIds); } catch (JsonException) { } } } if (claimedRoomInstanceIds.Any(value => value != inviterInstance.roomInstanceId)) { Console.WriteLine( $"[MATCH INVITE DENIED] inviter={inviterId.Value} " + $"currentInstance={inviterInstance.roomInstanceId} " + $"claimedInstances={string.Join(',', claimedRoomInstanceIds)} " + "reason=instance-mismatch"); return StatusCode(409, new { success = false, error = "room_invite_source_mismatch", currentRoomInstanceId = inviterInstance.roomInstanceId }); } targetIds.Remove(inviterId.Value); targetIds.ExceptWith(claimedRoomInstanceIds); Console.WriteLine( $"[MATCH INVITE] inviter={inviterId.Value} " + $"targets={string.Join(",", targetIds)} " + $"room={inviterInstance.roomId} " + $"instance={inviterInstance.roomInstanceId} " + $"contentType={Request.ContentType ?? "null"} " + $"rawBody={rawBody}"); if (targetIds.Count == 0) { return BadRequest(new { success = false, error = "No valid target player ID was provided.", contentType = Request.ContentType, receivedBody = rawBody }); } var joinedPlayers = new List(); var missingPlayers = new List(); var failedPlayers = new List(); foreach (long targetId in targetIds.Take(100)) { var targetPlayer = PlayerDB.Players.FindById(targetId); if (targetPlayer?.Player == null) { missingPlayers.Add(targetId); continue; } var relationship = RelationshipDB.GetClientRelationship( targetId, inviterId.Value); if (relationship?.Ignored is 1 or 3) { failedPlayers.Add(targetId); Console.WriteLine( $"[MATCH INVITE DENIED] inviter={inviterId.Value} " + $"target={targetId} reason=blocked-by-recipient"); continue; } joinedPlayers.Add(targetId); Console.WriteLine( $"[MATCH INVITE] Queued invite for player {targetId}; " + $"presence unchanged. room={inviterInstance.roomId} " + $"instance={inviterInstance.roomInstanceId} " + $"photonRoom={inviterInstance.photonRoomId}"); DiscordLogger.Log( $"📨 **Room Invite** — " + $"`{inviterPlayer.Player.Username ?? "unknown"}` " + $"(ID: `{inviterId.Value}`) invited player `{targetId}` " + $"to room `{inviterInstance.roomId}` " + $"instance `{inviterInstance.roomInstanceId}`"); await NotiController.NotifyRoomInviteAsync( inviterId.Value, targetId, inviterInstance.roomId, inviterInstance.roomInstanceId, inviterInstance.photonRoomId); } return Ok(new { success = joinedPlayers.Count > 0, invitedPlayerIds = joinedPlayers, joinedPlayerIds = Array.Empty(), pendingInviteIds = joinedPlayers, missingPlayerIds = missingPlayers, failedPlayerIds = failedPlayers, roomId = inviterInstance.roomId, subRoomId = inviterInstance.subRoomId, roomInstanceId = inviterInstance.roomInstanceId, photonRoomId = inviterInstance.photonRoomId }); } private static PlayerDBClasses.RoomInstance CloneRoomInstance( PlayerDBClasses.RoomInstance source) { ArgumentNullException.ThrowIfNull(source); return new PlayerDBClasses.RoomInstance { encryptVoiceChat = source.encryptVoiceChat, clubId = source.clubId, dataBlob = source.dataBlob, eventId = source.eventId, isFull = source.isFull, isInProgress = source.isInProgress, isPrivate = source.isPrivate, location = source.location, maxCapacity = source.maxCapacity, Name = source.Name, photonRegion = source.photonRegion, photonRegionId = source.photonRegionId, photonRoomId = source.photonRoomId, roomCode = source.roomCode, roomId = source.roomId, roomInstanceId = source.roomInstanceId, roomInstanceType = source.roomInstanceType, subRoomId = source.subRoomId, subRoomDataSaveId = source.subRoomDataSaveId, createdAt = source.createdAt }; } [HttpGet("room/{roomId}/instances")] public IActionResult GetRoomInstances(long roomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var requestedRoom = RoomDB.GetRoom(roomId); if (requestedRoom == null || ((requestedRoom.IsDorm || requestedRoom.Accessibility == RoomDBClasses.RoomAccessibility.Private) && !RoomDB.CanPlayerAccessRoom( requestedRoom, accountId.Value))) { return NotFound(); } const long activeWindowSeconds = 120; long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - activeWindowSeconds; var instances = PlayerDB.Players.FindAll() .Select(player => player.Player?.PlayerExtra?.Heartbeat) .Where(heartbeat => heartbeat != null && heartbeat.isOnline && heartbeat.lastHeartbeatUnixTime >= activeAfter && heartbeat.roomInstance != null && heartbeat.roomInstance.roomId == roomId && !Sessions.IsRestrictedInstance(heartbeat.roomInstance) && heartbeat.roomInstance.roomInstanceType == PlayerDBClasses.RoomInstanceType.Public) .GroupBy(heartbeat => heartbeat!.roomInstance!.roomInstanceId) .Select(group => { var instance = group.First()!.roomInstance!; var playerIds = group .Select(heartbeat => heartbeat!.playerId) .Where(id => id > 0 && id <= int.MaxValue) .Distinct() .OrderBy(id => id) .Select(id => (int)id) .ToList(); int capacity = Math.Max(1, instance.maxCapacity); DateTime createdAt = instance.createdAt == default ? DateTimeOffset.FromUnixTimeSeconds( group.Min(heartbeat => heartbeat!.lastHeartbeatUnixTime)) .UtcDateTime : instance.createdAt.ToUniversalTime(); return new { roomInstanceId = instance.roomInstanceId, roomId = instance.roomId, subRoomId = instance.subRoomId, isFull = instance.isFull || playerIds.Count >= capacity, createdAt, playerIds }; }) .OrderByDescending(instance => instance.playerIds.Count) .ThenBy(instance => instance.roomInstanceId) .ToList(); return Ok(instances); } private static readonly string[] InviteAccountIdKeys = { "id", "Id", "ids", "Ids", "accountId", "AccountId", "accountIds", "AccountIds", "playerId", "PlayerId", "playerIds", "PlayerIds", "targetId", "TargetId", "targetPlayerId", "TargetPlayerId", "targetAccountId", "TargetAccountId", "inviteeId", "InviteeId", "inviteeAccountId", "InviteeAccountId", "invitedPlayerId", "InvitedPlayerId", "invitedPlayerIds", "InvitedPlayerIds" }; private static readonly string[] RoomInstanceIdKeys = { "roomInstanceId", "RoomInstanceId", "requestedRoomInstanceId", "RequestedRoomInstanceId", "targetRoomInstanceId", "TargetRoomInstanceId" }; private static void AddInviteIds( string? value, HashSet output) { if (string.IsNullOrWhiteSpace(value)) return; string cleaned = Uri.UnescapeDataString(value) .Trim() .Trim('[', ']', '"', '\''); string[] parts = cleaned.Split( new[] { ',', ';', ' ', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (string part in parts) { string idText = part .Trim() .Trim('[', ']', '"', '\''); if (long.TryParse(idText, out long id) && id > 0) output.Add(id); } } private static void FindInviteIds( JsonElement element, HashSet output) { switch (element.ValueKind) { case JsonValueKind.Number: { if (element.TryGetInt64(out long numberId) && numberId > 0) { output.Add(numberId); } break; } case JsonValueKind.String: { AddInviteIds(element.GetString(), output); break; } case JsonValueKind.Array: { foreach (JsonElement item in element.EnumerateArray()) FindInviteIds(item, output); break; } case JsonValueKind.Object: { foreach (JsonProperty property in element.EnumerateObject()) { bool isIdProperty = InviteAccountIdKeys.Any(key => property.Name.Equals( key, StringComparison.OrdinalIgnoreCase)); if (isIdProperty) { FindInviteIds( property.Value, output); } else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { FindInviteIds( property.Value, output); } } break; } } } private static void FindRoomInstanceIds( JsonElement element, HashSet output) { if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (RoomInstanceIdKeys.Any(key => property.Name.Equals( key, StringComparison.OrdinalIgnoreCase))) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out long numericValue) && numericValue > 0) { output.Add(numericValue); } else if (property.Value.ValueKind == JsonValueKind.String) { AddInviteIds(property.Value.GetString(), output); } } else if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { FindRoomInstanceIds(property.Value, output); } } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) FindRoomInstanceIds(item, output); } } [HttpPost("matchmake/none")] public async Task MatchmakeNone() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); Sessions.MarkPlayerLeft(id.Value); Heartbeat? heartbeat = PlayerDB.LeaveCurrentRoom(id.Value); await NotiController.NotifyPlayerPresenceUpdatedAsync(id.Value); return Ok(heartbeat); } [HttpGet("rooms/requiring/{role}")] public IActionResult GetRoomsRequiringRole(string role) { if (!Enum.TryParse(role, true, out var parsedRole)) return Ok(Array.Empty()); var ids = RoomDB.Rooms.FindAll() .Where(r => r.Roles != null && r.Roles.Any(x => x.Role == parsedRole)) .Select(r => r.RoomId) .ToList(); return Ok(ids); } [HttpPut("/match/player/statusvisibility")] public IActionResult SetStatusVisibility([FromForm] int statusVisibility) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return NoContent(); } [HttpPut("/match/player/photonregionpings")] public IActionResult SetPhotonRegionPings() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return NoContent(); } [HttpPut("/match/player/gameserverregionpings")] public IActionResult SetGameServerRegionPings() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return NoContent(); } [HttpPut("/match/player/vrmovementmode")] public IActionResult SetVrMovementMode() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return NoContent(); } [HttpGet("/match/player/avoidjuniors")] [HttpPut("/match/player/avoidjuniors")] public IActionResult AvoidJuniors() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); return Ok(false); } [HttpGet("/match/player/connection-info")] public IActionResult GetPlayerConnectionInfo() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var heartbeat = PlayerDB.GetPlayerHeartbeat((long)id); var roomInstance = heartbeat?.roomInstance; var playerRecord = PlayerDB.Players.FindById((long)id); string photonTicket = PhotonTicketService.Issue( playerId: (long)id, roomInstanceId: roomInstance?.roomInstanceId, roomId: roomInstance?.roomId, displayName: playerRecord?.Player?.DisplayName ?? playerRecord?.Player?.Username); Response.Headers.CacheControl = "no-store"; return Ok(new { Enabled = ServerConfig.PhotonEnabled, AppId = ServerConfig.PhotonRealtimeAppId, AppVersion = ServerConfig.PhotonAppVersion, Region = string.IsNullOrWhiteSpace(roomInstance?.photonRegionId) ? ServerConfig.PhotonRegion : roomInstance!.photonRegionId, PhotonRegion = string.IsNullOrWhiteSpace(roomInstance?.photonRegion) ? ServerConfig.PhotonRegion : roomInstance!.photonRegion, UseCustomAuthentication = true, AuthenticationParameter = "token", PhotonAccessToken = photonTicket, UserId = id.ToString(), RoomId = roomInstance?.roomId, RoomInstanceId = roomInstance?.roomInstanceId, PhotonRoomId = roomInstance?.photonRoomId ?? string.Empty, IsInRoom = roomInstance != null }); } [HttpPost("/match/roominstance/{roomInstanceId}/reportjoinresult")] public async Task ReportJoinResult(long roomInstanceId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); string report = await ReadJoinResultReportAsync(); bool succeeded = !LooksLikeFailedJoinResult(report); bool applied = Sessions.ReportJoinResult( id.Value, roomInstanceId, succeeded); int consumedInviteRows = applied && succeeded ? NotificationDB.ConsumeRoomInvite( id.Value, roomInstanceId) : 0; if (applied) await NotiController.NotifyPlayerPresenceUpdatedAsync(id.Value); Console.WriteLine( $"[JOIN RESULT HTTP] player={id.Value} " + $"instance={roomInstanceId} success={succeeded} " + $"applied={applied} consumedInvite={consumedInviteRows} " + $"report={report}"); return Ok(new { success = succeeded, applied, consumedInviteRows }); } [HttpPost("matchmake/dorm")] public async Task MatchmakeDorm() { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); if (PlayerDB.NeedsOrientation(player)) { const long orientationRoomId = 13; var orientation = Sessions.CreateRoom( player.PlayerId, orientationRoomId, isPrivate: true); if (orientation != null) { PlayerDB.RecordRoomVisit(player.PlayerId, orientationRoomId); Console.WriteLine( $"[ORIENTATION] player={player.PlayerId} routed to room={orientationRoomId}"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(orientation); } Console.WriteLine( $"[ORIENTATION] room={orientationRoomId} unavailable; falling back to dorm"); } Heartbeat currentHeartbeat = PlayerDB.GetPlayerHeartbeat(player.PlayerId); if (Sessions.ShouldHoldInGuestDorm(player.PlayerId, currentHeartbeat)) { Console.WriteLine( $"[DORM GUEST HOLD] player={player.PlayerId} " + $"instance={currentHeartbeat.roomInstance?.roomInstanceId} " + "reason=recent-guest-dorm-skip-auto-reload"); return Ok(currentHeartbeat); } Heartbeat dormHeartbeat = Sessions.CreateDorm( player.PlayerId, player.Player.Username); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(dormHeartbeat); } [HttpPost("matchmake/room/{roomId}")] public async Task MatchmakeRoomRoomId(long roomId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); IActionResult? invitedResult = await TryJoinLatestInviteForRoomAsync( player.PlayerId, roomId); if (invitedResult != null) return invitedResult; bool isPrivate = await ReadPrivateMatchmakingFlagAsync(); var heartbeat = Sessions.CreateRoom( player.PlayerId, roomId, isPrivate: isPrivate); if (heartbeat == null) { return NotFound(new { success = false, error = "room_not_found_or_access_denied", errorMessage = "That room is unavailable or you do not have access.", roomId }); } if (heartbeat != null) { PlayerDB.RecordRoomVisit((long)player.PlayerId, roomId); var room = RoomDB.GetRoom(roomId); if (room != null && (room.Name.Contains("RecCenter", StringComparison.OrdinalIgnoreCase) || room.Name.Contains("Rec Center", StringComparison.OrdinalIgnoreCase))) { var (challengeMapId, _, _) = RecNetDB.GetCurrentChallengeWindow(); RecNetDB.SetChallengeProgress( player.PlayerId, challengeMapId, challengeId: 1, progress: 1, goal: 1); } } await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } [HttpPost("matchmake/private/room/{roomId:long}")] [HttpPost("matchmake/room/{roomId:long}/private")] public async Task MatchmakePrivateRoom(long roomId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); var heartbeat = Sessions.CreateRoom( player.PlayerId, roomId, isPrivate: true); if (heartbeat == null) return NotFound(); PlayerDB.RecordRoomVisit(player.PlayerId, roomId); Console.WriteLine( $"[PRIVATE MATCHMAKE] player={player.PlayerId} " + $"room={roomId} instance={heartbeat.roomInstance?.roomInstanceId}"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } private async Task TryJoinLatestInviteForRoomAsync( long playerId, long roomId) { if (!NotificationDB.TryGetLatestRoomInviteForRoom( playerId, roomId, out long inviteId, out long inviterPlayerId, out long roomInstanceId)) { return null; } Heartbeat? heartbeat = Sessions.JoinInvitedRoomInstance( playerId, roomInstanceId, inviterPlayerId, roomId); if (heartbeat?.roomInstance == null) { Console.WriteLine( $"[PRIVATE INVITE FALLBACK MISS] player={playerId} " + $"room={roomId} invite={inviteId} instance={roomInstanceId}"); return NotFound(new { success = false, error = "invited_room_instance_not_found_or_full", errorMessage = "The invited private instance is no longer available or is full.", roomId, inviteId, roomInstanceId }); } PlayerDB.RecordRoomVisit(playerId, roomId); Console.WriteLine( $"[PRIVATE INVITE FALLBACK JOIN] player={playerId} " + $"inviter={inviterPlayerId} room={roomId} " + $"invite={inviteId} instance={roomInstanceId} " + "grantPendingJoinResult=true"); await NotiController.NotifyPlayerPresenceUpdatedAsync(playerId); return Ok(heartbeat); } [HttpPost("matchmake/instance/{roomInstanceId:long}")] [HttpPut("matchmake/instance/{roomInstanceId:long}")] [HttpPost("matchmake/roominstance/{roomInstanceId:long}")] [HttpPut("matchmake/roominstance/{roomInstanceId:long}")] [HttpPost("roominstance/{roomInstanceId:long}/join")] [HttpPut("roominstance/{roomInstanceId:long}/join")] [HttpPost("/api/match/matchmake/instance/{roomInstanceId:long}")] [HttpPut("/api/match/matchmake/instance/{roomInstanceId:long}")] [HttpPost("/api/match/matchmake/roominstance/{roomInstanceId:long}")] [HttpPut("/api/match/matchmake/roominstance/{roomInstanceId:long}")] public Task MatchmakeRoomInstance(long roomInstanceId) => JoinRoomInstanceAsync(roomInstanceId); [HttpPost("matchmake/instance")] [HttpPut("matchmake/instance")] [HttpPost("/api/match/matchmake/instance")] [HttpPut("/api/match/matchmake/instance")] [RequestSizeLimit(32 * 1024)] public async Task MatchmakeRoomInstanceFromBody() { long roomInstanceId = 0; foreach (string key in new[] { "roomInstanceId", "RoomInstanceId", "instanceId", "InstanceId", "id", "Id", "inviteId", "InviteId" }) { if (long.TryParse( Request.Query[key].FirstOrDefault(), out roomInstanceId) && roomInstanceId > 0) { break; } } if (roomInstanceId <= 0 && Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); foreach (string key in new[] { "roomInstanceId", "RoomInstanceId", "instanceId", "InstanceId", "id", "Id", "inviteId", "InviteId" }) { if (long.TryParse( form[key].FirstOrDefault(), out roomInstanceId) && roomInstanceId > 0) { break; } } } else if (roomInstanceId <= 0 && ((Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0)) { using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync( HttpContext.RequestAborted); if (!long.TryParse(body.Trim().Trim('"'), out roomInstanceId)) { try { using JsonDocument document = JsonDocument.Parse(body); foreach (string key in new[] { "roomInstanceId", "RoomInstanceId", "instanceId", "InstanceId", "id", "Id", "inviteId", "InviteId" }) { if (TryReadJsonLong( document.RootElement, key, out roomInstanceId) && roomInstanceId > 0) { break; } } } catch (JsonException) { roomInstanceId = 0; } } } if (roomInstanceId <= 0) { return BadRequest(new { success = false, error = "room_instance_id_required" }); } return await JoinRoomInstanceAsync(roomInstanceId); } private async Task JoinRoomInstanceAsync( long roomInstanceId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); bool hasInvite = NotificationDB.TryGetRoomInvite( player.PlayerId, roomInstanceId, out long inviterPlayerId, out long invitedRoomId, out long grantedRoomInstanceId); long resolvedRoomInstanceId = hasInvite ? grantedRoomInstanceId : roomInstanceId; Heartbeat? heartbeat = hasInvite ? Sessions.JoinInvitedRoomInstance( player.PlayerId, resolvedRoomInstanceId, inviterPlayerId, invitedRoomId) : Sessions.JoinRoomInstance( player.PlayerId, resolvedRoomInstanceId); if (heartbeat?.roomInstance == null) { return NotFound(new { success = false, error = hasInvite ? "invited_room_instance_not_found_or_full" : "room_instance_not_found_or_full", errorMessage = "That room instance is no longer available or is full.", roomInstanceId = resolvedRoomInstanceId, requestedId = roomInstanceId }); } PlayerDB.RecordRoomVisit( player.PlayerId, heartbeat.roomInstance.roomId); Console.WriteLine( $"[GOTO] player={player.PlayerId} " + $"room={heartbeat.roomInstance.roomId} " + $"instance={heartbeat.roomInstance.roomInstanceId} " + $"viaInvite={hasInvite.ToString().ToLowerInvariant()} " + $"grantPendingJoinResult={hasInvite.ToString().ToLowerInvariant()}"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } [HttpPost("matchmake/invite/{inviteId:long}")] [HttpPut("matchmake/invite/{inviteId:long}")] [HttpPost("/api/match/matchmake/invite/{inviteId:long}")] [HttpPut("/api/match/matchmake/invite/{inviteId:long}")] public Task MatchmakeRoomInvite(long inviteId) => JoinRoomInviteAsync(inviteId); [HttpPost("matchmake/invite")] [HttpPut("matchmake/invite")] [HttpPost("/api/match/matchmake/invite")] [HttpPut("/api/match/matchmake/invite")] [RequestSizeLimit(32 * 1024)] public async Task MatchmakeRoomInviteFromBody() { long inviteId = 0; foreach (string key in new[] { "inviteId", "InviteId", "id", "Id", "roomInstanceId", "RoomInstanceId" }) { if (long.TryParse(Request.Query[key].FirstOrDefault(), out inviteId) && inviteId > 0) break; } if (inviteId <= 0 && Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); foreach (string key in new[] { "inviteId", "InviteId", "id", "Id", "roomInstanceId", "RoomInstanceId" }) { if (long.TryParse(form[key].FirstOrDefault(), out inviteId) && inviteId > 0) break; } } else if (inviteId <= 0 && ((Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0)) { using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (!long.TryParse(body.Trim().Trim('"'), out inviteId)) { try { using JsonDocument document = JsonDocument.Parse(body); foreach (string key in new[] { "inviteId", "InviteId", "id", "Id", "roomInstanceId", "RoomInstanceId" }) { if (TryReadJsonLong(document.RootElement, key, out inviteId) && inviteId > 0) break; } } catch (JsonException) { inviteId = 0; } } } if (inviteId <= 0) { return BadRequest(new { success = false, error = "invite_id_required" }); } return await JoinRoomInviteAsync(inviteId); } private async Task JoinRoomInviteAsync(long inviteId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); if (!NotificationDB.TryGetRoomInvite( player.PlayerId, inviteId, out long inviterPlayerId, out long invitedRoomId, out long roomInstanceId)) { Console.WriteLine( $"[MATCH INVITE MISS] player={player.PlayerId} " + $"postedId={inviteId} reason=no-message-or-grant"); return NotFound(new { success = false, error = "game_invite_not_found", errorMessage = "That room invitation has expired or is no longer available.", inviteId }); } var heartbeat = Sessions.JoinInvitedRoomInstance( player.PlayerId, roomInstanceId, inviterPlayerId, invitedRoomId); if (heartbeat?.roomInstance == null) { return NotFound(new { success = false, error = "invited_room_instance_not_found_or_full", errorMessage = "The invited room is no longer available or is full.", inviteId, roomInstanceId }); } PlayerDB.RecordRoomVisit(player.PlayerId, invitedRoomId); Console.WriteLine( $"[MATCH INVITE ACCEPT] player={player.PlayerId} " + $"inviter={inviterPlayerId} postedId={inviteId} " + $"room={invitedRoomId} instance={roomInstanceId} " + "grantPendingJoinResult=true"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } private static bool TryReadJsonLong( JsonElement element, string name, out long value) { value = 0; if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64(out value)) return value > 0; if (long.TryParse(property.Value.ToString(), out value)) return value > 0; } if (TryReadJsonLong(property.Value, name, out value)) return true; } } else if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) { if (TryReadJsonLong(item, name, out value)) return true; } } return false; } [HttpPost("matchmake/room/{roomId}/{subRoomId}")] public async Task MatchmakeRoomSubRoomId( long roomId, long subRoomId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); IActionResult? invitedResult = await TryJoinLatestInviteForRoomAsync( player.PlayerId, roomId); if (invitedResult != null) return invitedResult; var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) { return NotFound(new { success = false, error = "Room or subroom was not found.", roomId, subRoomId }); } bool isPrivate = await ReadPrivateMatchmakingFlagAsync(); var heartbeat = Sessions.CreateRoom( player.PlayerId, roomId, subRoomId, isPrivate); if (heartbeat == null) { return NotFound(new { success = false, error = "room_not_found_or_access_denied", errorMessage = "That room is unavailable or you do not have access.", roomId, subRoomId }); } PlayerDB.RecordRoomVisit(player.PlayerId, roomId); if (room.Name.Contains("RecCenter", StringComparison.OrdinalIgnoreCase) || room.Name.Contains("Rec Center", StringComparison.OrdinalIgnoreCase)) { var (challengeMapId, _, _) = RecNetDB.GetCurrentChallengeWindow(); RecNetDB.SetChallengeProgress( player.PlayerId, challengeMapId, challengeId: 1, progress: 1, goal: 1); } Console.WriteLine( $"[SUBROOM MATCHMAKE] player={player.PlayerId} room={roomId} " + $"subroom={subRoomId} instance={heartbeat.roomInstance?.roomInstanceId}"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } [HttpPost("matchmake/private/room/{roomId:long}/{subRoomId:long}")] [HttpPost("matchmake/room/{roomId:long}/{subRoomId:long}/private")] public async Task MatchmakePrivateRoomSubRoom( long roomId, long subRoomId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); var heartbeat = Sessions.CreateRoom( player.PlayerId, roomId, subRoomId, isPrivate: true); if (heartbeat == null) return NotFound(); PlayerDB.RecordRoomVisit(player.PlayerId, roomId); Console.WriteLine( $"[PRIVATE MATCHMAKE] player={player.PlayerId} " + $"room={roomId} subroom={subRoomId} " + $"instance={heartbeat.roomInstance?.roomInstanceId}"); await NotiController.NotifyPlayerPresenceUpdatedAsync(player.PlayerId); return Ok(heartbeat); } private async Task ReadPrivateMatchmakingFlagAsync() { foreach (string key in new[] { "isPrivate", "private", "createPrivate", "roomInstanceType", "instanceType" }) { foreach (string value in Request.Query[key]) { if (TryParsePrivateMatchmakingValue(key, value, out bool result)) return result; } } foreach (string key in new[] { "X-Room-Private", "X-Private-Room", "RoomInstanceType" }) { foreach (string value in Request.Headers[key]) { if (TryParsePrivateMatchmakingValue(key, value, out bool result)) return result; } } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); foreach (var field in form) { foreach (string value in field.Value) { if (TryParsePrivateMatchmakingValue( field.Key, value, out bool result)) { return result; } } } return false; } if ((Request.ContentLength ?? 0) <= 0) return false; using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) return false; try { using JsonDocument document = JsonDocument.Parse(body); foreach (JsonProperty property in document.RootElement.EnumerateObject()) { if (TryParsePrivateMatchmakingValue( property.Name, property.Value.ToString(), out bool result)) { return result; } } } catch (JsonException) { if (TryParsePrivateMatchmakingValue( "isPrivate", body, out bool result)) { return result; } } return false; } private static bool TryParsePrivateMatchmakingValue( string key, string? value, out bool result) { result = false; if (string.IsNullOrWhiteSpace(value)) return false; string normalized = value.Trim().Trim('"').ToLowerInvariant(); bool typeField = key.Contains( "type", StringComparison.OrdinalIgnoreCase); if (typeField) { if (normalized is "privatenewinstance" or "private_new_instance" or "private-new-instance" or "2") { result = true; return true; } if (normalized is "publicmatchmaking" or "public_matchmaking" or "public-matchmaking" or "publicnewinstance" or "public_new_instance" or "public-new-instance" or "public" or "0" or "1") { result = false; return true; } if (normalized == "private") { result = true; return true; } } if (normalized is "private" or "true" or "yes" or "on" || normalized == "1") { result = true; return true; } if (normalized is "public" or "false" or "no" or "off" || normalized == "0") { result = false; return true; } return false; } private async Task ReadJoinResultReportAsync() { var values = new List(); foreach (var query in Request.Query) { values.AddRange(query.Value.Select(value => $"{query.Key}={value}")); } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); foreach (var field in form) { values.AddRange(field.Value.Select(value => $"{field.Key}={value}")); } } else if ((Request.ContentLength ?? 0) > 0) { using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(body)) values.Add(body); } return string.Join("&", values); } private static bool LooksLikeFailedJoinResult(string report) { if (string.IsNullOrWhiteSpace(report)) return false; string normalized = report.ToLowerInvariant(); if (normalized.Contains("fail", StringComparison.Ordinal) || normalized.Contains("error", StringComparison.Ordinal) || normalized.Contains("timeout", StringComparison.Ordinal) || normalized.Contains("cancel", StringComparison.Ordinal) || normalized.Contains("success=false", StringComparison.Ordinal) || normalized.Contains("succeeded=false", StringComparison.Ordinal) || normalized.Contains("\"success\":false", StringComparison.Ordinal) || normalized.Contains("\"succeeded\":false", StringComparison.Ordinal)) { return true; } Match numericResult = Regex.Match( normalized, @"(?:^|[?&{,\s""])(?:joinresult|joinresultcode|result)" + @"[""']?\s*[:=]\s*[""']?(?-?\d+)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); return numericResult.Success && long.TryParse( numericResult.Groups["code"].Value, out long resultCode) && resultCode != 0; } } }