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