mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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-<guid>" /
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>The empty row this bug was seen on: instance 20965 of the RecCenter.</summary>
|
||||
private const string DeadPhotonRoomId = "Radicade44-1fe4247ce8794b7c897ef1987d2a593d";
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
||||
{
|
||||
private readonly DbContextOptions<RecDBContext> 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<MatchResponse> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user