mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
422 lines
17 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|