using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// A public room instance has no life of its own: Photon destroys the room the moment its /// last client leaves, and Radicade only keeps the row. So an instance with nobody in it is /// a photonRoomId pointing at a room that is gone. /// /// Handing that id to a client is how a room becomes permanently unjoinable: every /// matchmake re-selects the same empty row (it is the only non-full, non-private instance /// of the room), the client connects to a room name that no longer exists, the peer sits /// there until Photon times it out - "Failed to connect to room Radicade44-" / /// ClientTimeout - and the room load aborts. That is what the RecCenter did twice in the /// 2026-09-20 log: once on the realtime peer, once on the voice peer, ~15s of silence each /// time, while the dorm (a private instance, minted fresh per player) joined in under a /// second. /// /// These pin the sweep that now runs before public instance selection. /// [Collection(DbFactoryCollection.Name)] public class MatchmakeEmptyInstanceTests { private const long RoomId = 3; private const long SubRoomId = 3; private const long OtherRoomId = 25; private const long PlayerId = 2; /// The empty row this bug was seen on: instance 20965 of the RecCenter. private const string DeadPhotonRoomId = "Radicade44-1fe4247ce8794b7c897ef1987d2a593d"; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; } public RecDBContext CreateDbContext() => new(options); } private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return (factory, factory.CreateDbContext()); } private static void SeedRoom(RecDBContext db, long roomId = RoomId, long subRoomId = SubRoomId, string name = "RecCenter") { db.Rooms.Add(new RoomData { RoomId = roomId, Name = name, CreatorAccountId = 1, Accessibility = Accessibility.Public, MaxPlayers = 100, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { SubRoomId = subRoomId, RoomId = roomId, Name = "Home", UnitySceneId = "cbad71af-0831-44d8-b8ef-69edafa841f6", MaxPlayers = 100, Accessibility = Accessibility.Public, }); } private static RoomInstance SeedInstance( RecDBContext db, bool isPrivate = false, RoomInstanceType type = RoomInstanceType.Public, long roomId = RoomId, long subRoomId = SubRoomId, string photonRoomId = DeadPhotonRoomId) { var instance = new RoomInstance { roomId = roomId, subRoomId = subRoomId, roomInstanceType = type, isPrivate = isPrivate, maxCapacity = 100, name = "^RecCenter", location = "cbad71af-0831-44d8-b8ef-69edafa841f6", photonRoomId = photonRoomId, }; db.RoomInstances.Add(instance); return instance; } private static RealPresence AddPresence(RecDBContext db, long playerId, long? roomInstance, bool isOnline = true) { var presence = new RealPresence { playerId = playerId, isOnline = isOnline, roomInstance = roomInstance, errorCode = JoinStatus.Success, }; db.Presences.Add(presence); return presence; } private static async Task MatchmakeRecCenterAsync() { return await MatchmakingUtils.Matchmake( JoinMode.PublicMatchmaking, "RecCenter", PlayerId); } [Fact] public async Task AnEmptyPublicInstanceIsNeverHandedOut() { var (factory, db) = CreateContext(); await using var _db = db; SeedRoom(db); var dead = SeedInstance(db); AddPresence(db, PlayerId, null); await db.SaveChangesAsync(); var deadId = dead.roomInstanceId; var match = await MatchmakeRecCenterAsync(); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); // The join must land on a fresh instance, not on the room nobody can enter. Assert.NotEqual(deadId, match.roomInstance!.roomInstanceId); Assert.NotEqual(DeadPhotonRoomId, match.roomInstance.photonRoomId); // And the dead row must be gone, so the next attempt cannot pick it up either. await using var verify = factory.CreateDbContext(); Assert.Null(await verify.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == deadId)); } [Fact] public async Task AnInstanceWithSomebodyOnlineInItIsStillReused() { var (factory, db) = CreateContext(); await using var _db = db; SeedRoom(db); var live = SeedInstance(db); AddPresence(db, PlayerId, null); AddPresence(db, 7, live.roomInstanceId); await db.SaveChangesAsync(); var liveId = live.roomInstanceId; var match = await MatchmakeRecCenterAsync(); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.Equal(liveId, match.roomInstance!.roomInstanceId); await using var verify = factory.CreateDbContext(); Assert.NotNull(await verify.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == liveId)); } [Fact] public async Task AnInstanceHeldOnlyByAnOfflinePresenceIsReaped() { var (factory, db) = CreateContext(); await using var _db = db; SeedRoom(db); // A client that dropped after a failed join leaves its presence parked on the // instance. It counts as nobody in the room, so the room is gone. var parked = SeedInstance(db); AddPresence(db, PlayerId, null); AddPresence(db, 7, parked.roomInstanceId, isOnline: false); await db.SaveChangesAsync(); var parkedId = parked.roomInstanceId; var match = await MatchmakeRecCenterAsync(); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotEqual(parkedId, match.roomInstance!.roomInstanceId); await using var verify = factory.CreateDbContext(); Assert.Null(await verify.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == parkedId)); } [Fact] public async Task APrivateInstanceIsLeftAlone() { var (factory, db) = CreateContext(); await using var _db = db; SeedRoom(db); // An owner's private session is empty too, but its owner has to be able to walk // back in through matchmake/instance/{id}, so the sweep must not touch it. var private_ = SeedInstance(db, isPrivate: true, type: RoomInstanceType.Private); AddPresence(db, PlayerId, null); await db.SaveChangesAsync(); var privateId = private_.roomInstanceId; var match = await MatchmakeRecCenterAsync(); // Public matchmaking also must not drop the player into somebody's private room. Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotEqual(privateId, match.roomInstance!.roomInstanceId); await using var verify = factory.CreateDbContext(); Assert.NotNull(await verify.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == privateId)); } [Fact] public async Task TheSweepOnlyTouchesTheRoomBeingJoined() { var (factory, db) = CreateContext(); await using var _db = db; SeedRoom(db); SeedRoom(db, OtherRoomId, subRoomId: 32, name: "DormRoom"); var other = SeedInstance(db, roomId: OtherRoomId, subRoomId: 32, photonRoomId: "Radicade39-other"); AddPresence(db, PlayerId, null); await db.SaveChangesAsync(); var otherId = other.roomInstanceId; var match = await MatchmakeRecCenterAsync(); Assert.Equal(JoinStatus.Success, match.errorCode); await using var verify = factory.CreateDbContext(); Assert.NotNull(await verify.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == otherId)); } }