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

405 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<IEnumerable<BalanceResponse>>(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<IEnumerable<BalanceResponse>>(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<CompleteObjectivesDTO>());
var body = Assert.IsAssignableFrom<IEnumerable<ProgressionResponse>>(
Assert.IsType<OkObjectResult>(result.Result).Value);
var single = Assert.Single(body);
Assert.Equal(2, single.Level);
Assert.Equal(5, single.XP);
}
}