diff --git a/SVSim.Database/Services/RankProgress/IRankProgressService.cs b/SVSim.Database/Services/RankProgress/IRankProgressService.cs index 567ead6a..20125ff0 100644 --- a/SVSim.Database/Services/RankProgress/IRankProgressService.cs +++ b/SVSim.Database/Services/RankProgress/IRankProgressService.cs @@ -4,9 +4,13 @@ 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. +/// Wire-shape result of a single rank-battle finish grant. The wire-mapped fields +/// (, , , +/// , , , +/// ) map 1:1 to RankBattleFinishResponseDto +/// keys the client reads via GetValueOrDefault in Wizard/RankBattleFinishTask.cs:57-63. +/// is a controller-side signal for the rank_achieved mission +/// emit and is not serialized to the wire. /// /// rank_id post-grant (1..29). /// Point post-grant (accumulated). @@ -15,6 +19,10 @@ namespace SVSim.Database.Services.RankProgress; /// Signed MasterPoint delta. /// True iff Rank == 25. /// True iff Rank >= 26. +/// +/// True iff this grant crossed the viewer into a higher bucket +/// than they held pre-grant. Callsites gate rank_achieved mission emits on this flag. +/// public sealed record RankProgressResult( int Rank, int AfterBattlePoint, @@ -22,7 +30,8 @@ public sealed record RankProgressResult( int BattlePoint, int MasterPoint, bool IsMasterRank, - bool IsGrandMasterRank); + bool IsGrandMasterRank, + bool TierAdvanced = false); public interface IRankProgressService { diff --git a/SVSim.Database/Services/RankProgress/RankProgressService.cs b/SVSim.Database/Services/RankProgress/RankProgressService.cs index cc0fcac1..06322985 100644 --- a/SVSim.Database/Services/RankProgress/RankProgressService.cs +++ b/SVSim.Database/Services/RankProgress/RankProgressService.cs @@ -39,6 +39,9 @@ public sealed class RankProgressService : IRankProgressService viewer.RankProgress.Add(row); } + // Snapshot pre-tier for the TierAdvanced signal — compared to post-tier below. + string? preTier = RankTier.Name(CurrentRankId(row, byId)); + int deltaPoint = 0; int deltaMp = 0; int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000 @@ -81,6 +84,12 @@ public sealed class RankProgressService : IRankProgressService } int finalRank = CurrentRankId(row, byId); + string? postTier = RankTier.Name(finalRank); + // Tier "advanced" only on a promotion (pre != post AND both resolve). Demotion by + // point loss would technically change the tier string too, but rank_achieved is + // an achievement — it doesn't fire on going backward. + bool tierAdvanced = isWin && preTier != postTier && postTier is not null && preTier is not null; + return new RankProgressResult( Rank: finalRank, AfterBattlePoint: row.Point, @@ -88,7 +97,8 @@ public sealed class RankProgressService : IRankProgressService BattlePoint: deltaPoint, MasterPoint: deltaMp, IsMasterRank: finalRank == MasterRankId, - IsGrandMasterRank: finalRank >= FirstGrandMasterRankId); + IsGrandMasterRank: finalRank >= FirstGrandMasterRankId, + TierAdvanced: tierAdvanced); } public async Task GetAsync( diff --git a/SVSim.UnitTests/Services/RankProgressServiceTests.cs b/SVSim.UnitTests/Services/RankProgressServiceTests.cs index ef1b0d62..e7812165 100644 --- a/SVSim.UnitTests/Services/RankProgressServiceTests.cs +++ b/SVSim.UnitTests/Services/RankProgressServiceTests.cs @@ -242,4 +242,45 @@ public class RankProgressServiceTests Assert.That(r.AfterMasterPoint, Is.EqualTo(0)); Assert.That(v.RankProgress, Is.Empty); } + + // ---- TierAdvanced ---- + + [Test] + public async Task TierAdvanced_is_false_when_win_stays_within_same_tier() + { + // Point 100 (Beginner 1, rank 2) + 100 win = 200 (Beginner 3, rank 4). Still beginner. + var svc = NewService(); + var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0); + + var r = await svc.GrantAsync(v, Format.Rotation, isWin: true); + + Assert.That(RankTier.Name(r.Rank), Is.EqualTo("beginner")); + Assert.That(r.TierAdvanced, Is.False); + } + + [Test] + public async Task TierAdvanced_is_true_when_win_crosses_beginner_to_d() + { + // Point 1150 (Beginner 3, rank 4 — needs 1200 to leave beginner) + 100 = 1250 = D0 (rank 5). + var svc = NewService(); + var v = NewViewer(Format.Rotation, point: 1150, masterPoint: 0); + + var r = await svc.GrantAsync(v, Format.Rotation, isWin: true); + + Assert.That(r.Rank, Is.EqualTo(5), "Expected promotion to rank 5 (D0)"); + Assert.That(r.TierAdvanced, Is.True); + } + + [Test] + public async Task TierAdvanced_is_false_on_loss_even_if_tier_string_changes() + { + // Loss can't advance a tier — even if it demoted, the achievement doesn't fire backward. + // Point 1200 (D0, rank 5) - 50 loss = 1200 (floored). Still D0. Prove flag stays false. + 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.TierAdvanced, Is.False); + } }