using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// A level the server has just raised has to be visible without a relogin, on every screen that /// shows that player - not only on the player's own - and the only lever the client offers for that /// is one notification. /// /// The client keeps its notification handlers in a string-keyed table and delivers a frame only /// when its Id EQUALS a registered key. A dump of that table (the patch's dispatcher probe, run at /// login) shows exactly one progression entry, under the literal name /// "PlayerProgressionLevelUpdate" - there is no numeric id for it. The server used to announce a /// level change with an account update instead, which is why the new level and the new XP both /// only appeared after the game was restarted. /// public class ProgressionLevelUpdateTests { private const long PlayerId = 3; private const long RoomId = 7; private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } /// /// A hub context that records every frame it pushes, per connection. Which connection a frame /// went to is the whole point of the broadcast test, so a single shared list is not enough. /// private sealed class RecordingHub : IDisposable { private readonly IHubContext _context; private readonly List _previousClients; private readonly IHubContext? _previousContext; public RecordingHub() { var hubClients = DispatchProxy.Create(); ((HubClientsProxy)(object)hubClients).CreateClient = connectionId => { var recorder = new RecordingSingleClient(connectionId, this); ByConnection[connectionId] = recorder.Sent; return recorder; }; _context = DispatchProxy.Create, HubContextProxy>(); ((HubContextProxy)(object)_context).HubClients = hubClients; _previousContext = Notify.Context; _previousClients = Notify.Clients; Notify.Context = _context; Notify.Clients = new List(); } /// connection id -> the frames pushed down it, in order. public Dictionary> ByConnection { get; } = new(); /// Every frame pushed anywhere, in order. public List Sent { get; } = new(); public void AddConnection(long accountId, string connectionId, params long[] subscriptions) { Notify.Clients.Add(new HubClient { AccountId = accountId, ConnectionId = connectionId, Subscriptions = subscriptions.ToList(), HasSubscriptions = subscriptions.Length > 0, }); } public List For(string connectionId) => ByConnection.TryGetValue(connectionId, out var frames) ? frames : new List(); public void Dispose() { Notify.Context = _previousContext; Notify.Clients = _previousClients; } } private static ProgressionData Progression(long playerId, int level, int xp) => new() { PlayerId = playerId, Level = level, XP = xp, Progress = new ProgressionJson().ToJson(), }; [Fact] public async Task ALevelChangeIsPushedUnderTheNameTheClientRegistersItsHandlerFor() { using var db = CreateDb(); db.Progression.Add(Progression(PlayerId, 6, 420)); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); await RoomProgressionService.SendProgressionLevelUpdateAsync(db, PlayerId); var frame = Assert.Single(hub.Sent); Assert.Contains("\"Id\":\"PlayerProgressionLevelUpdate\"", frame); Assert.DoesNotContain("\"Id\":6", frame); } [Fact] public async Task TheFrameCarriesTheLevelAndTheXpIntoThatLevel() { using var db = CreateDb(); db.Progression.Add(Progression(PlayerId, 6, 420)); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); await RoomProgressionService.SendProgressionLevelUpdateAsync(db, PlayerId); var frame = Assert.Single(hub.Sent); using var doc = JsonDocument.Parse(frame); var msg = doc.RootElement.GetProperty("Msg"); // The bulk progression route is the one place the client shows a level from, and it serves // exactly these names - so the frame repeats them. The XP rides beside the level as // progress through it, not as the lifetime total the row holds: this row is level 6, which // begins at 90, so the 420 it has earned reads as 330. Handing over the total is what made // a finished level's XP carry into the next one. Assert.Equal(6, msg.GetProperty("Level").GetInt32()); Assert.Equal(330, msg.GetProperty("XP").GetInt32()); Assert.Equal(PlayerId, msg.GetProperty("PlayerId").GetInt64()); // ...and the lower-cased spellings too, because the client's own type for this frame is not // visible from here and its parser ignores a member it does not declare. Assert.Equal(6, msg.GetProperty("level").GetInt32()); Assert.Equal(330, msg.GetProperty("xp").GetInt32()); Assert.Equal(PlayerId, msg.GetProperty("playerId").GetInt64()); } [Fact] public async Task AnotherPlayerWatchingThemGetsTheFrameToo() { using var db = CreateDb(); db.Progression.Add(Progression(PlayerId, 2, 40)); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); // A second, different account that is watching this player - a friend, or anyone in the same // room. Each of them holds this player's level from what it fetched, and only a frame moves // it, so without this they keep showing the old level until they relaunch. hub.AddConnection(9001, "watcher", PlayerId); await RoomProgressionService.SendProgressionLevelUpdateAsync(db, PlayerId); Assert.Contains(hub.For("owner"), frame => frame.Contains("\"Id\":\"PlayerProgressionLevelUpdate\"", StringComparison.Ordinal)); Assert.Contains(hub.For("watcher"), frame => frame.Contains("\"Id\":\"PlayerProgressionLevelUpdate\"", StringComparison.Ordinal)); Assert.Contains(hub.For("watcher"), frame => frame.Contains("\"Level\":2", StringComparison.Ordinal)); } [Fact] public async Task AConnectionThatIsNotWatchingThemGetsNothing() { using var db = CreateDb(); db.Progression.Add(Progression(PlayerId, 2, 40)); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); hub.AddConnection(9001, "stranger", 42); await RoomProgressionService.SendProgressionLevelUpdateAsync(db, PlayerId); Assert.Empty(hub.For("stranger")); } [Fact] public async Task TheSharedPushEmitsTheProgressionFrameEvenWhenNothingElseCanBePushed() { // No profile row at all: every other push in the shared path bails out here, and a level the // panel just set would be invisible again if the progression frame were behind them. using var db = CreateDb(); db.Progression.Add(Progression(PlayerId, 9, 640)); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); await RoomProgressionService.PushProgressionUpdateAsync(db, PlayerId); Assert.Contains(hub.Sent, frame => frame.Contains("\"Id\":\"PlayerProgressionLevelUpdate\"", StringComparison.Ordinal)); } [Fact] public async Task ARoomXpGrantAnnouncesTheLevelUpAndZeroesTheClientsXp() { using var db = CreateDb(); // A session already anchored to this room whose last instalment is two intervals old: the // grant is due, and with it the announcement. var json = new ProgressionJson { RoomXpRoomId = RoomId, RoomXpEarned = 0, RoomXpLastGrantAt = DateTime.UtcNow.AddMinutes(-11), XpEarnedToday = 0, XpEarnedTodayDate = DateTime.UtcNow.Date, }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Level = 1, XP = 0, Progress = json.ToJson(), }); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); var granted = await RoomProgressionService.AwardRoomXpAsync(db, PlayerId, RoomId, isPublicRoom: true); // Eleven elapsed minutes is two intervals, so 20 XP - which is exactly the map's level 2. // The grant that levels the player up announces the new level with the XP reset to the // start of it, rather than the 20 they finished level 1 on. Assert.Equal(20, granted); Assert.Contains(hub.Sent, frame => frame.Contains("\"Id\":\"PlayerProgressionLevelUpdate\"", StringComparison.Ordinal)); Assert.Contains(hub.Sent, frame => frame.Contains("\"Level\":2", StringComparison.Ordinal)); Assert.Contains(hub.Sent, frame => frame.Contains("\"xp\":0", StringComparison.Ordinal)); } [Fact] public async Task TheMapsTokenDropPastTheTopLevelIsNeverHandedOut() { using var db = CreateDb(); // The progression map still names a token gift drop for level 51 (33363), so put that row in // the database: it is exactly what the old fallback handed out as a "Level Up Reward" box. db.GiftItems.Add(new GiftDropDB { GiftDropId = 33363, Name = "Level Up Reward", Description = "Level up reward", CurrencyAmount = 750, }); // Enough XP to clear level 50's cumulative requirement many times over; the level it lands // on is the top of the reward table, because the map's steps past 50 are never reached. db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Level = 1, XP = 40000, Progress = new ProgressionJson().ToJson(), }); await db.SaveChangesAsync(); // The XP has to reach the table's own top level, or this asserts nothing. Assert.True( LevelUpGiftService.ComputeLevel(LevelUpGiftService.ParseMap(), 40000) == LevelRewardTable.LastItemLevel, "the XP in this test must push the level to the top of the reward table"); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); var delivered = await LevelUpGiftService.DeliverLevelUpGiftsAsync( db, PlayerId, recomputeFromXp: true, fromLevel: 50, notify: false); // There is no level above the table to grant anything for: the XP computes to the table's // own top level, so the map's token drop for level 51 is unreachable - it is the fallback // for nothing, and it never becomes a "Level Up Reward" box. Assert.Empty(delivered); Assert.False(LevelRewardTable.IsItemLevel(LevelRewardTable.LastItemLevel + 1)); Assert.False(await db.Gifts.AnyAsync(x => x.AccountId == PlayerId)); Assert.DoesNotContain(hub.Sent, frame => frame.Contains("Level Up Reward", StringComparison.Ordinal)); } // ------------------------------------------------------------------------------------- // What each XP source pays // ------------------------------------------------------------------------------------- [Fact] public void TheThreeXpSourcesPayTheSpecifiedAmounts() { // 10 XP every 5 minutes in a public room, 1 for opening a gift box, 2 for finishing a game. Assert.Equal(10, RoomProgressionService.XpPerInterval); Assert.Equal(TimeSpan.FromMinutes(5), RoomProgressionService.Interval); Assert.Equal(1, ProgressionAwardService.XpPerGiftBoxOpened); Assert.Equal(2, ProgressionAwardService.XpPerGameCompleted); // The room's caps are XP amounts and were left where they were. Assert.Equal(60, RoomProgressionService.RoomXpCap); Assert.Equal(150, RoomProgressionService.DailyXpCap); } [Fact] public async Task XpFromGiftBoxesAndGamesAccumulatesAndRaisesTheLevelAtTheMapsThreshold() { using var db = CreateDb(); // The map is what decides the level, and its first step is 20 total XP for level 2. var map = LevelUpGiftService.ParseMap(); Assert.Equal(1, LevelUpGiftService.ComputeLevel(map, 19)); Assert.Equal(2, LevelUpGiftService.ComputeLevel(map, 20)); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); hub.AddConnection(9001, "watcher", PlayerId); // Twenty XP earned a point or two at a time - the two new sources, not room time: ten gift // boxes at 1 XP, then five completed games at 2 XP. for (var i = 0; i < 10; i++) await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); for (var i = 0; i < 5; i++) await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGameCompleted, "game completed"); // Persisted, not held in memory... var stored = await db.Progression.FirstAsync(x => x.PlayerId == PlayerId); Assert.Equal(20, stored.XP); // ...and every point of it counted toward the level. Assert.Equal(2, stored.Level); // Each grant also told the client, including the player watching them. Assert.Contains(hub.For("watcher"), frame => frame.Contains("\"Level\":2", StringComparison.Ordinal)); } [Fact] public async Task AGiftBoxAwardIsNotCountedAgainstTheRoomAndDailyCaps() { // The caps bound time-in-room earning; the other two sources are not time-in-room. using var db = CreateDb(); var json = new ProgressionJson { RoomXpRoomId = RoomId, RoomXpEarned = 0, RoomXpLastGrantAt = DateTime.UtcNow.AddMinutes(-6), XpEarnedToday = 0, XpEarnedTodayDate = DateTime.UtcNow.Date, }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Level = 1, XP = 0, Progress = json.ToJson(), }); await db.SaveChangesAsync(); using var hub = new RecordingHub(); hub.AddConnection(PlayerId, "owner"); await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); // A room instalment still pays in full, and the gift's point did not consume any of it. var granted = await RoomProgressionService.AwardRoomXpAsync(db, PlayerId, RoomId, isPublicRoom: true); Assert.Equal(10, granted); Assert.Equal(11, (await db.Progression.FirstAsync(x => x.PlayerId == PlayerId)).XP); } public class HubContextProxy : DispatchProxy { public IHubClients HubClients = null!; protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name == "get_Clients" ? HubClients : throw new NotSupportedException(); } public class HubClientsProxy : DispatchProxy { public Func CreateClient = null!; protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name == "Client" ? CreateClient((string)args![0]!) : throw new NotSupportedException(); } private sealed class RecordingSingleClient : ISingleClientProxy { private readonly string _connectionId; private readonly RecordingHub _hub; public RecordingSingleClient(string connectionId, RecordingHub hub) { _connectionId = connectionId; _hub = hub; } public List Sent { get; } = new(); public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { var frame = Assert.IsType(args[0]); Sent.Add(frame); _hub.Sent.Add(frame); return Task.CompletedTask; } } }