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);
+ }
+}