using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Players.V2; using RadicadeServer.API.Storefront.V4; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Responses; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// A client paints one bare XP number beside a player's level, and handing it the lifetime total /// made a finished level's XP carry into the next one: a player who leveled up at 50 XP still read /// 50 on the new level. The client is now told their progress THROUGH the current level instead, /// which restarts at zero on a level-up. /// /// The lifetime total is never reduced. It is what decides the level and what the game's own /// award path keeps adding to - this is a change to the /// number the client is shown, not to the XP a player has earned. /// public class ProgressionXpResetTests { private const long PlayerId = 11; private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private static ProgressionData Progression(int level, int xp) => new() { PlayerId = PlayerId, Level = level, XP = xp, Progress = new ProgressionJson().ToJson(), }; [Fact] public void TheThresholdsAreTheCumulativeStepsTheLevelIsComputedFrom() { var map = LevelUpGiftService.ParseMap(); // The shipped map: ten XP for the first three levels, twenty through level ten, then the // wider tiers - each level's threshold being every step up to and including it. Assert.Equal(10, LevelUpGiftService.ThresholdXpForLevel(map, 1)); Assert.Equal(20, LevelUpGiftService.ThresholdXpForLevel(map, 2)); Assert.Equal(90, LevelUpGiftService.ThresholdXpForLevel(map, 6)); Assert.Equal(170, LevelUpGiftService.ThresholdXpForLevel(map, 10)); Assert.Equal(620, LevelUpGiftService.ThresholdXpForLevel(map, 20)); Assert.Equal(1770, LevelUpGiftService.ThresholdXpForLevel(map, 30)); Assert.Equal(5370, LevelUpGiftService.ThresholdXpForLevel(map, 40)); Assert.Equal(16170, LevelUpGiftService.ThresholdXpForLevel(map, 50)); } [Fact] public void EveryThresholdIsExactlyTheXpThatComputesToThatLevel() { // The two halves have to be the same walk of the same map, or the number the client is // shown would be measured against a level it is not on. var map = LevelUpGiftService.ParseMap(); // Only up to the top level: past it the map's steps are never reached, because the // level itself stops at 50. foreach (var entry in map.Where(entry => entry.Level > 1 && entry.Level <= LevelUpGiftService.MaxLevel)) { var threshold = LevelUpGiftService.ThresholdXpForLevel(map, entry.Level); Assert.Equal(entry.Level, LevelUpGiftService.ComputeLevel(map, threshold)); // ...and a single XP short of it is still the level below. Assert.Equal(entry.Level - 1, LevelUpGiftService.ComputeLevel(map, threshold - 1)); } } [Fact] public void LandingOnEveryLevelLeavesTheClientFacingXpAtZero() { // This is the fix, asserted at every level the map names: arriving on a level shows zero, // rather than whatever the previous level finished on. var map = LevelUpGiftService.ParseMap(); // The top level is the exception, asserted on its own below: its XP is filled, not zero. foreach (var entry in map.Where(entry => entry.Level >= 1 && entry.Level < LevelUpGiftService.MaxLevel)) { var threshold = LevelUpGiftService.ThresholdXpForLevel(map, entry.Level); Assert.Equal(0, LevelUpGiftService.XpIntoCurrentLevel(map, entry.Level, threshold)); } } [Fact] public void TheNumberBesideTheLevelIsProgressThroughIt_NotTheLifetimeTotal() { var map = LevelUpGiftService.ParseMap(); // The reported case, with the level computed the way the server computes it: 420 lifetime // XP reaches level 15, and level 15 begins at 395 - so the number beside the level reads // 25, not the 420 the player has ever earned. Assert.Equal(15, LevelUpGiftService.ComputeLevel(map, 420)); Assert.Equal(25, LevelUpGiftService.XpIntoCurrentLevel(map, 15, 420)); // Part-way through a level, it is simply how far in they are. Assert.Equal(5, LevelUpGiftService.XpIntoCurrentLevel(map, 2, 25)); } [Fact] public void AFreshPlayersXpIsNeverShownAsNegative() { // The map's first step is ten XP, so a brand-new level 1 player sits below their own // level's threshold until they have earned it. The floor keeps that off the screen. var map = LevelUpGiftService.ParseMap(); Assert.Equal(0, LevelUpGiftService.XpIntoCurrentLevel(map, 1, 0)); Assert.Equal(0, LevelUpGiftService.XpIntoCurrentLevel(map, 1, 9)); } [Fact] public void TheProgressionRouteServesTheLevelRelativeXpAndTheSameLevel() { // The route the client fetches levels from. The level is unchanged - it was computed from // the total - but the XP it ships alongside is progress through that level: 420 is level // 15, which begins at 395. var response = new ProgressionResponse(Progression(15, 420)); Assert.Equal(15, response.Level); Assert.Equal(25, response.XP); } [Fact] public void TheRealtimeFrameCarriesTheLevelRelativeXpUnderBothCasings() { var notification = new PlayerProgressionLevelUpdateNotification(Progression(2, 25)); Assert.Equal(2, notification.Level); Assert.Equal(5, notification.XP); using var json = JsonDocument.Parse(JsonSerializer.Serialize(notification)); Assert.Equal(5, json.RootElement.GetProperty("XP").GetInt32()); Assert.Equal(5, json.RootElement.GetProperty("xp").GetInt32()); } [Fact] public async Task ALevelUpThroughTheAwardPathZeroesTheClientsXpButKeepsTheTotal() { using var db = CreateDb(); // One XP short of level 2 (the map's level 2 lands at 20 total), so a single award crosses. db.Progression.Add(Progression(1, 19)); await db.SaveChangesAsync(); await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); // Nothing is deleted: every point still counts toward the total, and the total is what // decided the new level. Assert.Equal(20, stored.XP); Assert.Equal(2, stored.Level); // ...but the earlier level's XP is not carried into the new one. Assert.Equal(0, LevelUpGiftService.XpIntoCurrentLevel(stored)); Assert.Equal(0, new ProgressionResponse(stored).XP); // The next point is progress into level 2, not another reset total. await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); Assert.Equal(21, stored.XP); Assert.Equal(1, new ProgressionResponse(stored).XP); } [Fact] public async Task TheWatchsXpBalanceIsTheLevelRelativeNumber() { // XP is carried as the RecRoyale_Season1 storefront balance, and the balance endpoint is // where the client reads the number it paints as the player's XP. This row has just // reached level 2, so that number is zero rather than the 20 they finished level 1 on. using var db = CreateDb(); db.Progression.Add(Progression(2, 20)); await db.SaveChangesAsync(); var api = new StorefrontsV4(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString()) }, "TestAuth")) } } }; var balances = await api.GetBalance(CurrencyType.RecRoyale_Season1); var balance = Assert.Single(Assert.IsAssignableFrom>(balances)); Assert.Equal(CurrencyType.RecRoyale_Season1, balance.CurrencyType); Assert.Equal(0, balance.Balance); } [Fact] public void TheLevelStopsAtTheGamesTopLevel() { var map = LevelUpGiftService.ParseMap(); // 50 is the cap. The map goes on naming steps all the way to level 87, but the reward table // ends at 50 and so does progression: XP past level 50's threshold holds the level where it // is instead of walking into levels the game has no content for. Assert.Equal(50, LevelUpGiftService.MaxLevel); Assert.Equal(49, LevelUpGiftService.ComputeLevel(map, 15090)); Assert.Equal(50, LevelUpGiftService.ComputeLevel(map, 16170)); Assert.Equal(50, LevelUpGiftService.ComputeLevel(map, 16440)); Assert.Equal(50, LevelUpGiftService.ComputeLevel(map, 40000)); Assert.Equal(50, LevelUpGiftService.ComputeLevel(map, int.MaxValue)); } [Fact] public void TheTopLevelsXpIsFilledToItsOwnRequirement() { var map = LevelUpGiftService.ParseMap(); // Level 50 is a 1080-XP level like the rest of its tier, so a player who has reached it // reads a full bar - not the zero a level-up resets the levels below it to. Assert.Equal(1080, LevelUpGiftService.RequiredXpForLevel(map, 50)); Assert.Equal(1080, LevelUpGiftService.XpIntoCurrentLevel(map, 50, 16170)); Assert.Equal(1080, LevelUpGiftService.XpIntoCurrentLevel(map, 50, 16440)); Assert.Equal(1080, LevelUpGiftService.XpIntoCurrentLevel(map, 50, 40000)); // A row the map itself would have called level 57 reads the same. Assert.Equal(1080, LevelUpGiftService.XpIntoCurrentLevel(map, 57, 40000)); } [Fact] public void TheLevelsBelowTheTopStillRestartAtZero() { var map = LevelUpGiftService.ParseMap(); // The filled bar is the top level's alone: landing on level 49 still starts at zero. Assert.Equal(1080, LevelUpGiftService.RequiredXpForLevel(map, 49)); Assert.Equal(0, LevelUpGiftService.XpIntoCurrentLevel(map, 49, 15090)); Assert.Equal(1, LevelUpGiftService.XpIntoCurrentLevel(map, 49, 15091)); } [Fact] public void AStoredLevelPastTheTopIsReportedAsTheTopLevel() { // Both surfaces the client reads progression from hold the level at the cap, so a row // written while the map still ran past 50 cannot show a level the game has no content for. var response = new ProgressionResponse(Progression(57, 40000)); Assert.Equal(50, response.Level); Assert.Equal(1080, response.XP); var notification = new PlayerProgressionLevelUpdateNotification(Progression(99, 40000)); Assert.Equal(50, notification.Level); Assert.Equal(1080, notification.XP); } [Fact] public void TheRewardTableRunsToTheTopLevel() { // Boxes and the cap are the same level, so raising one cannot leave the other behind. Assert.Equal(LevelUpGiftService.MaxLevel, LevelRewardTable.LastItemLevel); Assert.True(LevelRewardTable.IsItemLevel(LevelUpGiftService.MaxLevel)); Assert.False(LevelRewardTable.IsItemLevel(LevelUpGiftService.MaxLevel + 1)); } [Fact] public async Task ReachingTheTopLevelLeavesTheClientsXpFilledRatherThanZeroed() { using var db = CreateDb(); // One XP short of level 50 (the map's level 50 lands at 16170 total). db.Progression.Add(Progression(49, 16169)); await db.SaveChangesAsync(); await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); // The total still grew by the point earned, and that is what set the level... Assert.Equal(16170, stored.XP); Assert.Equal(50, stored.Level); // ...but the number beside it is the level filled to its requirement, not zero. Assert.Equal(1080, new ProgressionResponse(stored).XP); // And it stays filled as more XP lands, rather than overflowing or emptying again. await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGiftBoxOpened, "gift box"); stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); Assert.Equal(16171, stored.XP); Assert.Equal(50, stored.Level); Assert.Equal(1080, new ProgressionResponse(stored).XP); } [Fact] public async Task TheWatchsXpBalanceIsFilledAtTheTopLevel() { // The balance endpoint is where the client reads the number it paints as XP, and at the top // level that number is the level's full requirement rather than the lifetime total. using var db = CreateDb(); db.Progression.Add(Progression(50, 17000)); await db.SaveChangesAsync(); var api = new StorefrontsV4(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString()) }, "TestAuth")) } } }; var balances = await api.GetBalance(CurrencyType.RecRoyale_Season1); var balance = Assert.Single(Assert.IsAssignableFrom>(balances)); Assert.Equal(1080, balance.Balance); } [Fact] public void ALevelTheXpDoesNotPayForStillShowsXpMoving() { var map = LevelUpGiftService.ParseMap(); // A level set ahead of the XP - what an admin's level edit leaves behind - used to pin the // number at zero for good: 25 XP is nowhere near level 49's 15090, so subtracting it never // came out positive and nothing the player earned moved it. Assert.Equal(2, LevelUpGiftService.ComputeLevel(map, 25)); Assert.Equal(5, LevelUpGiftService.XpIntoCurrentLevel(map, 49, 25)); Assert.Equal(6, LevelUpGiftService.XpIntoCurrentLevel(map, 49, 26)); // The same for a row claiming level 5 (70 XP) on 25: the number tracks the XP earned. Assert.Equal(5, LevelUpGiftService.XpIntoCurrentLevel(map, 5, 25)); Assert.Equal(7, LevelUpGiftService.XpIntoCurrentLevel(map, 5, 27)); } [Fact] public async Task XpEarnedOnALevelAheadOfTheXpIsStillVisibleToTheClient() { using var db = CreateDb(); // The row an admin's level edit leaves behind: level 49 holding 20 XP. db.Progression.Add(Progression(49, 20)); await db.SaveChangesAsync(); await ProgressionAwardService.AwardXpAsync(db, PlayerId, ProgressionAwardService.XpPerGameCompleted, "game completed"); var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); // The level is left where the edit put it, and the XP the game paid still shows: 22 total is // two points into level 2, not the frozen zero the level-49 threshold produced. Assert.Equal(22, stored.XP); Assert.Equal(49, stored.Level); Assert.Equal(2, new ProgressionResponse(stored).XP); } [Fact] public async Task TheObjectivesRouteServesTheSameLevelRelativeXpAsEveryOtherSurface() { using var db = CreateDb(); db.Progression.Add(Progression(2, 25)); await db.SaveChangesAsync(); var api = new PlayersV2(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString()) }, "TestAuth")) } } }; var result = await api.CompleteObjectives(new List()); var body = Assert.IsAssignableFrom>( Assert.IsType(result.Result).Value); var single = Assert.Single(body); Assert.Equal(2, single.Level); Assert.Equal(5, single.XP); } }