Compare commits

..

2 Commits

Author SHA1 Message Date
Alexa 24cc13084a Whitelist api.{apex} as a shared public host (#34)
api.{apex} is the client's primary API host, served by path
dispatch across every service. It belonged to no route group's
HostSubdomains, so it was absent from PublicSubdomains and thus
from the monolith's HostFiltering AllowedHosts — every request to
api.{apex} was rejected with 400 "Bad Request - Invalid Hostname".

Append "api" alongside admin/feed so it is whitelisted while
remaining path-routed (it owns no service by host).
2026-06-30 13:35:42 +03:00
Alexa 193d9b343c Matchmade sessions respect room's subroom Max Player Count (#35)
* Avoid login kick broadcast during reconnect

* feat: Matchmade sessions respect room's subroom Max Player Count

GameSessionService previously hardcoded MaxCapacity=8 for new sessions,
fragmenting matchmade joiners 8-per-instance regardless of the host's
'Max Player Count in This Subroom' slider. New sessions now resolve the
room's entry subroom (lowest OrderIndex) RoomSceneEntity.MaxPlayers,
clamped 1-80, falling back to 8 for system rooms (dorm GUID roomIds).
2026-06-30 13:35:14 +03:00
2 changed files with 37 additions and 13 deletions
@@ -20,7 +20,6 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController] [ApiController]
public class MatchPlayerController( public class MatchPlayerController(
PlayerPresenceService presence, PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms, RoomService rooms,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
IConfiguration config, IConfiguration config,
@@ -37,7 +36,7 @@ public class MatchPlayerController(
/// </summary> /// </summary>
[HttpPost("/player/login")] [HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{ {
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -46,17 +45,15 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( 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); playerId);
// The new session hasn't opened its SignalR socket yet // Do not broadcast ModerationKick to the whole player group
// (negotiate fires AFTER /player/login on the watch's // here. During reconnects the active watch can already have a
// boot sequence — verified in the live trace), so the // SignalR connection in that group, and the 2020 client crashes
// ModerationKick push hits only the OLDER session's // hard if it receives a malformed/stale moderation payload.
// existing connections. They'll show the "you've been // NotifyHub.OnConnectedAsync snapshots existing connection ids
// kicked" dialog and gracefully disconnect via the // before adding the new connection and sends a targeted kick to
// watch's LocalModerationKickSelf path. // those ids only.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
} }
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
+28 -1
View File
@@ -64,7 +64,12 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save // in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS. // again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending", 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, PlayerCount = 1,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
@@ -75,6 +80,28 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session); 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;
/// <summary>Resolve a new session's capacity from the room's entry
/// subroom (lowest <c>OrderIndex</c>) <see cref="RoomSceneEntity.MaxPlayers"/>
/// — 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.</summary>
private async Task<int> 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<GameSession?> GetByIdAsync(long id) public async Task<GameSession?> GetByIdAsync(long id)
{ {
var s = await db.GameSessions.AsNoTracking() var s = await db.GameSessions.AsNoTracking()