using DorkNet.Models.GameSessions; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; namespace DorkNet.Server.Services; /// /// Cross-replica matchmaking session registry. Each session is a Photon /// match with a player count; finds an /// existing non-full session in the requested room+region or creates /// a new one. /// /// Pre-PR-3 used a process-local ConcurrentDictionary + atomic /// counter. With horizontal scale that was unsafe two ways: /// /// Each replica's counter started at 1, so two replicas would /// hand out colliding session ids — clients on different replicas /// could end up with the same SessionId pointing at different /// Photon rooms. /// "Find a non-full session" only saw replica-local sessions, so /// two players matchmaking via different replicas got their own /// fresh session each and were isolated. /// /// /// Both are fixed by moving to Postgres: bigserial allocates from a /// shared sequence; the SELECT sees every replica's sessions. /// Service is now scoped because it holds a DbContext. /// public class GameSessionService(DorkNetDbContext db) { // Dorm room is always available private const string DormRoomId = "00000000-0000-0000-0000-000000000001"; private const string DormActivityId = "76d98498-60a1-430c-ab76-b54a29b7a163"; public async Task JoinOrCreateAsync(string? roomId, string? activityId, string region) { var effectiveRoom = roomId ?? DormRoomId; // Find an existing non-full session for this room+region. ORDER BY // PlayerCount DESC + LIMIT 1 packs joiners into the fullest matching // session first, so we don't fragment players across half-empty // rooms when the catalog is light. var match = await db.GameSessions .Where(s => s.RoomId == effectiveRoom && s.Region == region && s.PlayerCount < s.MaxCapacity) .OrderByDescending(s => s.PlayerCount) .FirstOrDefaultAsync(); if (match is not null) { match.PlayerCount += 1; await db.SaveChangesAsync(); return ToDto(match); } var session = new GameSessionEntity { RoomId = effectiveRoom, ActivityLevelId = activityId ?? DormActivityId, Region = region, // PhotonRoomName needs the eventual server-allocated Id baked // 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, PlayerCount = 1, CreatedAt = DateTime.UtcNow, }; db.GameSessions.Add(session); await db.SaveChangesAsync(); session.PhotonRoomName = $"recroom_{session.Id}_{Guid.NewGuid():N}"; await db.SaveChangesAsync(); return ToDto(session); } public async Task GetByIdAsync(long id) { var s = await db.GameSessions.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == id); return s is null ? null : ToDto(s); } public async Task PlayerLeftAsync(long sessionId) { var s = await db.GameSessions.FirstOrDefaultAsync(s => s.Id == sessionId); if (s is null) return; s.PlayerCount = Math.Max(0, s.PlayerCount - 1); if (s.PlayerCount == 0) { db.GameSessions.Remove(s); } await db.SaveChangesAsync(); } private static GameSession ToDto(GameSessionEntity s) => new() { GameSessionId = s.Id, RoomId = s.RoomId, ActivityLevelId = s.ActivityLevelId, RegionId = s.Region, PhotonRoomName = s.PhotonRoomName, Private = false, MaxCapacity = s.MaxCapacity, PlayerCount = s.PlayerCount, IsFull = s.PlayerCount >= s.MaxCapacity, GameInProgress = s.PlayerCount > 0, }; }