diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 883de03..d5252c6 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -20,7 +20,6 @@ namespace DorkNet.Server.Controllers.Match; [ApiController] public class MatchPlayerController( PlayerPresenceService presence, - NotificationService notifications, RoomService rooms, PrivateInstanceService privateInstances, IConfiguration config, @@ -37,7 +36,7 @@ public class MatchPlayerController( /// [HttpPost("/player/login")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] - public async Task PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) + public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) { var playerId = TryGetCurrentPlayerId(); if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) @@ -46,17 +45,15 @@ public class MatchPlayerController( if (replaced) { log.LogWarning( - "[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push", + "[player-login] concurrent session marker replaced for player={PlayerId}; stale SignalR connections will be evicted on hub connect", playerId); - // The new session hasn't opened its SignalR socket yet - // (negotiate fires AFTER /player/login on the watch's - // boot sequence — verified in the live trace), so the - // ModerationKick push hits only the OLDER session's - // existing connections. They'll show the "you've been - // kicked" dialog and gracefully disconnect via the - // watch's LocalModerationKickSelf path. - await notifications.KickStaleSession(playerId, - "Your account was signed in on another device."); + // Do not broadcast ModerationKick to the whole player group + // here. During reconnects the active watch can already have a + // SignalR connection in that group, and the 2020 client crashes + // hard if it receives a malformed/stale moderation payload. + // NotifyHub.OnConnectedAsync snapshots existing connection ids + // before adding the new connection and sends a targeted kick to + // those ids only. } } return Ok(Array.Empty()); diff --git a/DorkNet.Server/Services/GameSessionService.cs b/DorkNet.Server/Services/GameSessionService.cs index 71d2aab..0c9c4ea 100644 --- a/DorkNet.Server/Services/GameSessionService.cs +++ b/DorkNet.Server/Services/GameSessionService.cs @@ -64,7 +64,12 @@ public class GameSessionService(DorkNetDbContext db) // in, so we save once with a placeholder, then patch and save // again. Two SaveChangesAsync calls is cheap at our QPS. PhotonRoomName = "pending", - MaxCapacity = 8, + // Track the room's "Max Player Count in This Subroom" slider + // instead of a hardcoded 8, so matchmade joiners fill the room to + // the host's chosen limit rather than fragmenting into a new + // instance every 8 players. (Existing sessions keep whatever cap + // they were created with.) + MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom), PlayerCount = 1, CreatedAt = DateTime.UtcNow, }; @@ -75,6 +80,28 @@ public class GameSessionService(DorkNetDbContext db) return ToDto(session); } + // Default session cap when we can't resolve a room-specific value (system + // rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity). + private const int DefaultMaxCapacity = 8; + + /// Resolve a new session's capacity from the room's entry + /// subroom (lowest OrderIndex) + /// — the value the in-game "Max Player Count in This Subroom" slider + /// writes. Matchmade sessions land in the entry subroom, so its cap is the + /// right ceiling. GameSession roomIds are the room's numeric Id as a + /// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped + /// to a sane Photon ceiling. + private async Task ResolveRoomMaxCapacityAsync(string roomId) + { + if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity; + var max = await db.RoomScenes + .Where(s => s.RoomId == roomLongId) + .OrderBy(s => s.OrderIndex) + .Select(s => (int?)s.MaxPlayers) + .FirstOrDefaultAsync(); + return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity; + } + public async Task GetByIdAsync(long id) { var s = await db.GameSessions.AsNoTracking()