Files
Radicade/ServerTests/ProgressionLevelUpdateTests.cs
T
2026-10-16 23:33:13 -07:00

422 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private sealed class RecordingHub : IDisposable
{
private readonly IHubContext<NotifyHub> _context;
private readonly List<HubClient> _previousClients;
private readonly IHubContext<NotifyHub>? _previousContext;
public RecordingHub()
{
var hubClients = DispatchProxy.Create<IHubClients, HubClientsProxy>();
((HubClientsProxy)(object)hubClients).CreateClient = connectionId =>
{
var recorder = new RecordingSingleClient(connectionId, this);
ByConnection[connectionId] = recorder.Sent;
return recorder;
};
_context = DispatchProxy.Create<IHubContext<NotifyHub>, HubContextProxy>();
((HubContextProxy)(object)_context).HubClients = hubClients;
_previousContext = Notify.Context;
_previousClients = Notify.Clients;
Notify.Context = _context;
Notify.Clients = new List<HubClient>();
}
/// <summary>connection id -> the frames pushed down it, in order.</summary>
public Dictionary<string, List<string>> ByConnection { get; } = new();
/// <summary>Every frame pushed anywhere, in order.</summary>
public List<string> 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<string> For(string connectionId) =>
ByConnection.TryGetValue(connectionId, out var frames) ? frames : new List<string>();
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<string, ISingleClientProxy> 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<string> Sent { get; } = new();
public Task<T> InvokeCoreAsync<T>(string method, object?[] args, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
var frame = Assert.IsType<string>(args[0]);
Sent.Add(frame);
_hub.Sent.Add(frame);
return Task.CompletedTask;
}
}
}