diff --git a/SVSim.Database/Services/RankProgress/IRankProgressService.cs b/SVSim.Database/Services/RankProgress/IRankProgressService.cs new file mode 100644 index 00000000..567ead6a --- /dev/null +++ b/SVSim.Database/Services/RankProgress/IRankProgressService.cs @@ -0,0 +1,46 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Services.RankProgress; + +/// +/// Wire-shape result of a single rank-battle finish grant. All fields map 1:1 to +/// RankBattleFinishResponseDto keys the client reads via GetValueOrDefault +/// in Wizard/RankBattleFinishTask.cs:57-63. +/// +/// rank_id post-grant (1..29). +/// Point post-grant (accumulated). +/// MasterPoint post-grant (accumulated). +/// Signed Point delta (+100/-50/0). +/// Signed MasterPoint delta. +/// True iff Rank == 25. +/// True iff Rank >= 26. +public sealed record RankProgressResult( + int Rank, + int AfterBattlePoint, + int AfterMasterPoint, + int BattlePoint, + int MasterPoint, + bool IsMasterRank, + bool IsGrandMasterRank); + +public interface IRankProgressService +{ + /// + /// Applies a +100 (win) or -50 (loss) delta to the viewer's rank progression in the + /// given format, respecting tier floors from ranks.csv's LowerLimitPoint column. + /// Creates a ViewerRankProgress row for (viewer, format) if none exists. Caller must + /// have loaded the viewer with .Include(v => v.RankProgress) and must call + /// SaveChangesAsync after this returns. + /// + /// Must be Format.Rotation or Format.Unlimited. + Task GrantAsync( + Viewer viewer, Format format, bool isWin, CancellationToken ct = default); + + /// + /// Read-only current progression snapshot for (viewer, format). Returns a zero-value + /// result if no row exists. Does not mutate the viewer or hit the DB. + /// + Task GetAsync( + Viewer viewer, Format format, CancellationToken ct = default); +} diff --git a/SVSim.Database/Services/RankProgress/RankProgressService.cs b/SVSim.Database/Services/RankProgress/RankProgressService.cs new file mode 100644 index 00000000..cc0fcac1 --- /dev/null +++ b/SVSim.Database/Services/RankProgress/RankProgressService.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.Logging; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Globals; + +namespace SVSim.Database.Services.RankProgress; + +public sealed class RankProgressService : IRankProgressService +{ + // Simple constants for v1. Promote to a [ConfigSection("RankProgress")] if per-mode + // tuning is needed later (mirror BattleXpConfig's shape). + public const int PointsPerWin = 100; + public const int PointsPerLoss = 50; + + private const int MasterRankId = 25; + private const int FirstGrandMasterRankId = 26; + + private readonly IGlobalsRepository _globals; + private readonly ILogger _log; + + private Dictionary? _byId; + + public RankProgressService(IGlobalsRepository globals, ILogger log) + { + _globals = globals; + _log = log; + } + + public async Task GrantAsync( + Viewer viewer, Format format, bool isWin, CancellationToken ct = default) + { + EnsureSupportedFormat(format); + var byId = await LoadRanksAsync(); + + var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format); + if (row is null) + { + row = new ViewerRankProgress { Format = format, Point = 0, MasterPoint = 0 }; + viewer.RankProgress.Add(row); + } + + int deltaPoint = 0; + int deltaMp = 0; + int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000 + + if (isWin) + { + if (row.Point < masterFloor) + { + deltaPoint = PointsPerWin; + row.Point += PointsPerWin; + } + else + { + deltaMp = PointsPerWin; + row.MasterPoint += PointsPerWin; + } + } + else + { + if (row.MasterPoint > 0) + { + // Once you're at Grand Master (rank_id >= 26), MP can never drop back to + // Master. Floor = the Master→GM0 threshold from ranks.csv (5000). + int rankBeforeDemotion = CurrentRankId(row, byId); + int mpFloor = rankBeforeDemotion >= FirstGrandMasterRankId + ? byId[MasterRankId].AccumulateMasterPoint + : 0; + int newMp = Math.Max(row.MasterPoint - PointsPerLoss, mpFloor); + deltaMp = newMp - row.MasterPoint; + row.MasterPoint = newMp; + } + else + { + int currentRank = CurrentRankId(row, byId); + int floor = byId[currentRank].LowerLimitPoint; + int newPoint = Math.Max(row.Point - PointsPerLoss, floor); + deltaPoint = newPoint - row.Point; + row.Point = newPoint; + } + } + + int finalRank = CurrentRankId(row, byId); + return new RankProgressResult( + Rank: finalRank, + AfterBattlePoint: row.Point, + AfterMasterPoint: row.MasterPoint, + BattlePoint: deltaPoint, + MasterPoint: deltaMp, + IsMasterRank: finalRank == MasterRankId, + IsGrandMasterRank: finalRank >= FirstGrandMasterRankId); + } + + public async Task GetAsync( + Viewer viewer, Format format, CancellationToken ct = default) + { + var byId = await LoadRanksAsync(); + var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format) + ?? new ViewerRankProgress { Format = format }; + int rank = CurrentRankId(row, byId); + return new RankProgressResult( + Rank: rank, + AfterBattlePoint: row.Point, + AfterMasterPoint: row.MasterPoint, + BattlePoint: 0, + MasterPoint: 0, + IsMasterRank: rank == MasterRankId, + IsGrandMasterRank: rank >= FirstGrandMasterRankId); + } + + private static void EnsureSupportedFormat(Format format) + { + if (format != Format.Rotation && format != Format.Unlimited) + throw new ArgumentOutOfRangeException(nameof(format), + $"RankProgressService supports only Rotation/Unlimited, got {format}."); + } + + /// + /// Highest rank_id whose entry conditions the viewer meets. + /// - MP >= previous rank's AccumulateMasterPoint threshold → GM tier (26..29) + /// - Point >= 50000 (MasterRankId.LowerLimitPoint) → 25 (Master) + /// - Otherwise: smallest rank_id in 1..24 where Point < AccumulatePoint. + /// + private static int CurrentRankId(ViewerRankProgress row, Dictionary byId) + { + for (int gm = 29; gm >= FirstGrandMasterRankId; gm--) + { + int threshold = byId[gm - 1].AccumulateMasterPoint; + if (threshold > 0 && row.MasterPoint >= threshold) + return gm; + } + + if (row.Point >= byId[MasterRankId].LowerLimitPoint) + return MasterRankId; + + for (int r = 1; r <= 24; r++) + { + if (row.Point < byId[r].AccumulatePoint) return r; + } + return 24; // guardrail; shouldn't be reachable. + } + + private async Task> LoadRanksAsync() + { + if (_byId is not null) return _byId; + var rows = await _globals.GetRankInfo(); + if (rows.Count == 0) + { + _log.LogWarning("RankProgressService: RankInfo table is empty; grant will no-op."); + } + _byId = rows.ToDictionary(r => r.Id); + return _byId; + } +} diff --git a/SVSim.UnitTests/Services/RankProgressServiceTests.cs b/SVSim.UnitTests/Services/RankProgressServiceTests.cs new file mode 100644 index 00000000..ef1b0d62 --- /dev/null +++ b/SVSim.UnitTests/Services/RankProgressServiceTests.cs @@ -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(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.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(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); + } +}