using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Matchmaking; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; 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; /// /// The account-creation flow ends on the launch-game step, and the client asks for its dorm by /// route (matchmake/dorm) rather than by room name. That route resolves the dorm directly, /// so it never ran through the Orientation redirect that lives on the room-name path in /// - a brand-new player was dropped in their dorm with /// the Orientation marker still unspent. Orientation is a solo room (MaxPlayers 1), so the /// hand-off mints one instance per player, the same shape the dorm uses. /// /// These pin both dorm entry points, that the hand-off happens exactly once, and that a server /// without an Orientation room still lands its players somewhere. /// [Collection(DbFactoryCollection.Name)] public class OrientationHandoffTests { private const long PlayerId = 812; private const long OrientationRoomId = 13; private const long OrientationSubRoomId = 113; private const long DormRoomId = 8121; private const long DormSubRoomId = 8122; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } private static async Task<(TestDbContextFactory Factory, RecDBContext Db)> CreateContextAsync( bool seedOrientationRoom = true) { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; var db = factory.CreateDbContext(); if (seedOrientationRoom) { db.Rooms.Add(new RoomData { RoomId = OrientationRoomId, Name = "Orientation", Description = "An introductory tour of Rec Room!", Accessibility = Accessibility.Public, MaxPlayers = 1, CreatedAt = DateTime.UtcNow }); db.SubRooms.Add(new SubRoom { SubRoomId = OrientationSubRoomId, RoomId = OrientationRoomId, Name = "Home", UnitySceneId = "c79709d8-a31b-48aa-9eb8-cc31ba9505e8", MaxPlayers = 1, Accessibility = Accessibility.Public }); } // The player's own dorm, which GetOrCreateDorm recognises by owner rather than by name. db.Rooms.Add(new RoomData { RoomId = DormRoomId, Name = "DormRoom", IsDorm = true, CreatorAccountId = PlayerId, Accessibility = Accessibility.Private, CreatedAt = DateTime.UtcNow }); db.SubRooms.Add(new SubRoom { SubRoomId = DormSubRoomId, RoomId = DormRoomId, Name = "Home", UnitySceneId = "cbad71af-0831-44d8-b8ef-69edafa841f6", MaxPlayers = 4, Accessibility = Accessibility.Private }); await db.SaveChangesAsync(); return (factory, db); } /// /// An account the account-creation flow just created, with the settings that flow writes: /// the Orientation marker, and no HAS_COMPLETED_ORIENTATION. /// private static async Task SeedOnboardingPlayerAsync( RecDBContext db, long playerId = PlayerId, bool needsOrientation = true) { var settings = new List { new() { Key = "Recroom.AccountCreation.HasStarted", Value = "True" }, new() { Key = "Recroom.AccountCreation.HasFinished", Value = "False" }, }; if (needsOrientation) settings.Add(new Setting { Key = "Radicade.NeedsOrientation", Value = "1" }); db.PlayerSettings.Add(new PlayerSettings { AccountId = playerId, Settings = settings.ToJson() }); db.Presences.Add(new RealPresence { playerId = playerId }); await db.SaveChangesAsync(); } private static MatchmakingAPI CreateMatchmakingApi(RecDBContext db, long accountId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); var api = new MatchmakingAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; return api; } private static async Task MatchmakeDormAsync(MatchmakingAPI api) { var result = Assert.IsType(await api.MatchmakeDorm()); return Assert.IsType(result.Value); } private static async Task StillOwesOrientationAsync(TestDbContextFactory factory) { await using var verify = factory.CreateDbContext(); var settings = await verify.PlayerSettings.SingleAsync(x => x.AccountId == PlayerId); return settings.DeserializeSettings() .Any(setting => setting.Key == "Radicade.NeedsOrientation"); } [Fact] public async Task TheLaunchStepTheOnboardingFlowEndsOnOpensOrientation() { var (factory, db) = await CreateContextAsync(); await using var _db = db; await SeedOnboardingPlayerAsync(db); var match = await MatchmakeDormAsync(CreateMatchmakingApi(db, PlayerId)); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); Assert.Equal(OrientationRoomId, match.roomInstance!.roomId); await using var verify = factory.CreateDbContext(); var presence = await verify.Presences.SingleAsync(x => x.playerId == PlayerId); Assert.Equal(match.roomInstance.roomInstanceId, presence.roomInstance); Assert.False(await StillOwesOrientationAsync(factory)); } [Fact] public async Task TheRoomNameDormPathOpensOrientationToo() { var (factory, db) = await CreateContextAsync(); await using var _db = db; await SeedOnboardingPlayerAsync(db); var match = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "DormRoom", PlayerId); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); Assert.Equal(OrientationRoomId, match.roomInstance!.roomId); Assert.False(await StillOwesOrientationAsync(factory)); } [Fact] public async Task OrientationIsHandedOutOnceAndThenTheDormIsWhereTheyLand() { var (factory, db) = await CreateContextAsync(); await using var _db = db; await SeedOnboardingPlayerAsync(db); var first = await MatchmakeDormAsync(CreateMatchmakingApi(db, PlayerId)); Assert.Equal(OrientationRoomId, first.roomInstance!.roomId); // The marker was spent, so the second launch is an ordinary dorm launch: the player is not // sent back to Orientation every time they go home. var second = await MatchmakeDormAsync(CreateMatchmakingApi(db, PlayerId)); await using var verify = factory.CreateDbContext(); var room = await verify.Rooms.AsNoTracking().SingleAsync(x => x.RoomId == second.roomInstance!.roomId); Assert.True(room.IsDorm); Assert.Equal(DormRoomId, room.RoomId); Assert.NotEqual(first.roomInstance.roomInstanceId, second.roomInstance!.roomInstanceId); } [Fact] public async Task APlayerWhoOwesNoOrientationGoesStraightToTheirDorm() { var (factory, db) = await CreateContextAsync(); await using var _db = db; await SeedOnboardingPlayerAsync(db, needsOrientation: false); var match = await MatchmakeDormAsync(CreateMatchmakingApi(db, PlayerId)); await using var verify = factory.CreateDbContext(); var room = await verify.Rooms.AsNoTracking().SingleAsync(x => x.RoomId == match.roomInstance!.roomId); Assert.Equal(DormRoomId, room.RoomId); Assert.True(room.IsDorm); } [Fact] public async Task AServerWithNoOrientationRoomStillLandsThePlayerInTheirDorm() { var (factory, db) = await CreateContextAsync(seedOrientationRoom: false); await using var _db = db; await SeedOnboardingPlayerAsync(db); var match = await MatchmakeDormAsync(CreateMatchmakingApi(db, PlayerId)); await using var verify = factory.CreateDbContext(); var room = await verify.Rooms.AsNoTracking().SingleAsync(x => x.RoomId == match.roomInstance!.roomId); Assert.Equal(DormRoomId, room.RoomId); // Nothing was handed out, so the marker stays: the player still gets Orientation if the room // is added later. Assert.True(await StillOwesOrientationAsync(factory)); } }