feat(rank): RankProgressService — pure +100/-50 tier-floored math
Reads RankInfoEntry.LowerLimitPoint from ranks.csv (via IGlobalsRepository) for main-tier floors — no hardcoded thresholds. Master (Point=50000) and Grand Master (MP-based, floor 5000) branch handles the post-Master path. GetAsync is a read-only snapshot used later by AI-start's SelfInfo. 13 unit tests cover first-win, loss floors (0, Beginner→D, D→D0), win crossing 50000, MP awards at Master, GM promotion at MP≥5000, GM floor protecting against Master demotion, format separation, Crossover throw. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
245
SVSim.UnitTests/Services/RankProgressServiceTests.cs
Normal file
245
SVSim.UnitTests/Services/RankProgressServiceTests.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Services.RankProgress;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class RankProgressServiceTests
|
||||
{
|
||||
// Values mirror SVSim.Bootstrap/Data/ranks.csv verbatim: RankId, NecessaryPoint,
|
||||
// AccumulatePoint, LowerLimitPoint, AccumulateMasterPoint.
|
||||
private static readonly (int Id, int Necessary, int Accumulate, int Lower, int AccMp)[] Ranks =
|
||||
{
|
||||
(1, 100, 100, 0, 0), (2, 100, 200, 0, 0),
|
||||
(3, 500, 700, 0, 0), (4, 500, 1200, 0, 0),
|
||||
(5, 750, 1950, 1200, 0), (6, 750, 2700, 1200, 0),
|
||||
(7, 800, 3500, 1200, 0), (8, 1000, 4500, 1200, 0),
|
||||
(9, 1500, 6000, 4500, 0), (10,1500, 7500, 4500, 0),
|
||||
(11,1750, 9250, 4500, 0), (12,1750, 11000, 4500, 0),
|
||||
(13,2000, 13000, 11000, 0), (14,2000, 15000, 11000, 0),
|
||||
(15,2000, 17000, 11000, 0), (16,2000, 19000, 11000, 0),
|
||||
(17,3000, 23000, 20000, 0), (18,3000, 26000, 20000, 0),
|
||||
(19,3000, 29000, 20000, 0), (20,3000, 32000, 20000, 0),
|
||||
(21,4000, 37000, 33000, 0), (22,4000, 42000, 33000, 0),
|
||||
(23,4000, 46000, 33000, 0), (24,4000, 50000, 33000, 0),
|
||||
(25, 0, 0, 50000, 5000),
|
||||
(26, 0, 0, 50000, 15000),
|
||||
(27, 0, 0, 50000, 25000),
|
||||
(28, 0, 0, 50000, 35000),
|
||||
(29, 0, 0, 50000, 0),
|
||||
};
|
||||
|
||||
private static IGlobalsRepository RankRepo()
|
||||
{
|
||||
var mock = new Mock<IGlobalsRepository>(MockBehavior.Loose);
|
||||
mock.Setup(x => x.GetRankInfo()).ReturnsAsync(
|
||||
Ranks.Select(r => new RankInfoEntry
|
||||
{
|
||||
Id = r.Id,
|
||||
NecessaryPoint = r.Necessary,
|
||||
AccumulatePoint = r.Accumulate,
|
||||
LowerLimitPoint = r.Lower,
|
||||
AccumulateMasterPoint = r.AccMp,
|
||||
}).ToList());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static Viewer NewViewer(Format format, int point, int masterPoint)
|
||||
{
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
v.RankProgress.Add(new ViewerRankProgress
|
||||
{
|
||||
Format = format, Point = point, MasterPoint = masterPoint,
|
||||
});
|
||||
return v;
|
||||
}
|
||||
|
||||
private static RankProgressService NewService() =>
|
||||
new(RankRepo(), NullLogger<RankProgressService>.Instance);
|
||||
|
||||
[Test]
|
||||
public async Task First_win_from_scratch_awards_100_and_promotes_to_Beginner1()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(2));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.IsMasterRank, Is.False);
|
||||
Assert.That(r.IsGrandMasterRank, Is.False);
|
||||
var row = v.RankProgress.Single();
|
||||
Assert.That(row.Format, Is.EqualTo(Format.Rotation));
|
||||
Assert.That(row.Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_0_points_floors_at_0()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 0, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_within_beginner_can_drop_between_subranks()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(-50));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_D_tier_floor_stays_at_D()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1200, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(5));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_within_D_drops_from_D1_toward_D0_and_stops_at_floor()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1230, masterPoint: 0);
|
||||
|
||||
var r1 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
Assert.That(r1.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r1.BattlePoint, Is.EqualTo(-30));
|
||||
Assert.That(r1.Rank, Is.EqualTo(5));
|
||||
|
||||
var r2 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
Assert.That(r2.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r2.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_crossing_50000_lands_at_Master_with_no_MP_change()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 49950, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50050));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.IsMasterRank, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_at_Master_awards_MasterPoint_not_Point()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(100));
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MP_crossing_5000_promotes_to_GrandMaster_0()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 4900);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
|
||||
Assert.That(r.Rank, Is.EqualTo(26));
|
||||
Assert.That(r.IsGrandMasterRank, Is.True);
|
||||
Assert.That(r.IsMasterRank, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_GM0_floor_stays_at_5000_and_does_not_demote_to_Master()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 5000);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.Rank, Is.EqualTo(26));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_Master_with_MP_zero_leaves_Point_at_50000()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50000));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rotation_and_Unlimited_progressions_are_separate()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
v.RankProgress.Add(new ViewerRankProgress { Format = Format.Rotation, Point = 500 });
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Unlimited, isWin: true);
|
||||
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
|
||||
Assert.That(v.RankProgress.Count, Is.EqualTo(2));
|
||||
Assert.That(v.RankProgress.Single(x => x.Format == Format.Rotation).Point, Is.EqualTo(500));
|
||||
Assert.That(v.RankProgress.Single(x => x.Format == Format.Unlimited).Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crossover_format_throws()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
|
||||
await svc.GrantAsync(v, Format.Crossover, isWin: true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAsync_returns_zero_when_no_row_exists()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
var r = await svc.GetAsync(v, Format.Rotation);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(v.RankProgress, Is.Empty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user