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));
}
}