feat(rank-progress): RankProgressResult carries TierAdvanced signal
RankProgressResult gains a TierAdvanced bool (defaulted false so the 7-arg positional-constructor sites don't break). Set true iff the grant is a win AND RankTier.Name resolves both pre-grant and post-grant to distinct strings — i.e. a promotion crossed a tier boundary. Explicitly guards on isWin so a demotion (in principle possible after a loss below a tier floor, though the current floor rules prevent it) never fires the achievement backward. Three new tests: no-cross win, beginner→d cross, loss stays false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,13 @@ using SVSim.Database.Models;
|
|||||||
namespace SVSim.Database.Services.RankProgress;
|
namespace SVSim.Database.Services.RankProgress;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire-shape result of a single rank-battle finish grant. All fields map 1:1 to
|
/// Wire-shape result of a single rank-battle finish grant. The wire-mapped fields
|
||||||
/// <c>RankBattleFinishResponseDto</c> keys the client reads via GetValueOrDefault
|
/// (<see cref="Rank"/>, <see cref="AfterBattlePoint"/>, <see cref="AfterMasterPoint"/>,
|
||||||
/// in Wizard/RankBattleFinishTask.cs:57-63.
|
/// <see cref="BattlePoint"/>, <see cref="MasterPoint"/>, <see cref="IsMasterRank"/>,
|
||||||
|
/// <see cref="IsGrandMasterRank"/>) map 1:1 to <c>RankBattleFinishResponseDto</c>
|
||||||
|
/// keys the client reads via GetValueOrDefault in Wizard/RankBattleFinishTask.cs:57-63.
|
||||||
|
/// <see cref="TierAdvanced"/> is a controller-side signal for the rank_achieved mission
|
||||||
|
/// emit and is not serialized to the wire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Rank">rank_id post-grant (1..29).</param>
|
/// <param name="Rank">rank_id post-grant (1..29).</param>
|
||||||
/// <param name="AfterBattlePoint">Point post-grant (accumulated).</param>
|
/// <param name="AfterBattlePoint">Point post-grant (accumulated).</param>
|
||||||
@@ -15,6 +19,10 @@ namespace SVSim.Database.Services.RankProgress;
|
|||||||
/// <param name="MasterPoint">Signed MasterPoint delta.</param>
|
/// <param name="MasterPoint">Signed MasterPoint delta.</param>
|
||||||
/// <param name="IsMasterRank">True iff Rank == 25.</param>
|
/// <param name="IsMasterRank">True iff Rank == 25.</param>
|
||||||
/// <param name="IsGrandMasterRank">True iff Rank >= 26.</param>
|
/// <param name="IsGrandMasterRank">True iff Rank >= 26.</param>
|
||||||
|
/// <param name="TierAdvanced">
|
||||||
|
/// True iff this grant crossed the viewer into a higher <see cref="RankTier"/> bucket
|
||||||
|
/// than they held pre-grant. Callsites gate rank_achieved mission emits on this flag.
|
||||||
|
/// </param>
|
||||||
public sealed record RankProgressResult(
|
public sealed record RankProgressResult(
|
||||||
int Rank,
|
int Rank,
|
||||||
int AfterBattlePoint,
|
int AfterBattlePoint,
|
||||||
@@ -22,7 +30,8 @@ public sealed record RankProgressResult(
|
|||||||
int BattlePoint,
|
int BattlePoint,
|
||||||
int MasterPoint,
|
int MasterPoint,
|
||||||
bool IsMasterRank,
|
bool IsMasterRank,
|
||||||
bool IsGrandMasterRank);
|
bool IsGrandMasterRank,
|
||||||
|
bool TierAdvanced = false);
|
||||||
|
|
||||||
public interface IRankProgressService
|
public interface IRankProgressService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public sealed class RankProgressService : IRankProgressService
|
|||||||
viewer.RankProgress.Add(row);
|
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 deltaPoint = 0;
|
||||||
int deltaMp = 0;
|
int deltaMp = 0;
|
||||||
int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000
|
int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000
|
||||||
@@ -81,6 +84,12 @@ public sealed class RankProgressService : IRankProgressService
|
|||||||
}
|
}
|
||||||
|
|
||||||
int finalRank = CurrentRankId(row, byId);
|
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(
|
return new RankProgressResult(
|
||||||
Rank: finalRank,
|
Rank: finalRank,
|
||||||
AfterBattlePoint: row.Point,
|
AfterBattlePoint: row.Point,
|
||||||
@@ -88,7 +97,8 @@ public sealed class RankProgressService : IRankProgressService
|
|||||||
BattlePoint: deltaPoint,
|
BattlePoint: deltaPoint,
|
||||||
MasterPoint: deltaMp,
|
MasterPoint: deltaMp,
|
||||||
IsMasterRank: finalRank == MasterRankId,
|
IsMasterRank: finalRank == MasterRankId,
|
||||||
IsGrandMasterRank: finalRank >= FirstGrandMasterRankId);
|
IsGrandMasterRank: finalRank >= FirstGrandMasterRankId,
|
||||||
|
TierAdvanced: tierAdvanced);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RankProgressResult> GetAsync(
|
public async Task<RankProgressResult> GetAsync(
|
||||||
|
|||||||
@@ -242,4 +242,45 @@ public class RankProgressServiceTests
|
|||||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||||
Assert.That(v.RankProgress, Is.Empty);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user