feat(mission): add RankTier + Class/Ranked/Free/Challenge/ClassLevel/Rank builders

MissionEventKeys grows six new nested static classes covering the wire-up
gap for the counter families that Practice/Story left uncovered. Each mirrors
the established (Practice/Story) shape: named accessors for individual keys,
`{Family}.WinAll(...)` / `AchievedAll(...)` etc. as convenience arrays that
callers pass straight to RecordEventAsync.

RankTier lives at SVSim.Database/Enums/RankTier.cs since the concept (rank_id
→ tier bucket) is data-shaped, not emit-specific, and RankProgressService
will need it internally for tier-crossing detection. Ranked/ClassLevel/Rank
builders in MissionEventKeys compose Class.Name and RankTier.Name.

12 new registry tests cover the family shapes plus tier boundary edges
(rank 4/5, 24/25, 25/26).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 13:15:11 -04:00
parent 0f6c8d5e41
commit f3c8b46469
3 changed files with 281 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Rank-tier bucket names used by mission/achievement catalog rows keyed as
/// <c>rank_achieved:{tier}</c>. Boundaries pinned from ranks.csv:
/// <list type="bullet">
/// <item>1-4 = Beginner 0-3</item>
/// <item>5-8 = D0-D3</item>
/// <item>9-12 = C0-C3</item>
/// <item>13-16 = B0-B3</item>
/// <item>17-20 = A0-A3</item>
/// <item>21-24 = AA0-AA3</item>
/// <item>25 = Master</item>
/// <item>26-29 = Grand Master (G026-G029)</item>
/// </list>
/// The current prod catalog has no <c>rank_achieved:grand_master</c> rows, but the
/// emit is still faithful — a grand-master promotion advances the top-level
/// <c>rank_achieved</c> counter and the tier-qualified one (which no catalog row
/// reads yet, but stays consistent if one lands later).
/// </summary>
public static class RankTier
{
/// <summary>Wire rank_id → catalog-facing tier name. Null iff <paramref name="rankId"/> is out of range.</summary>
public static string? Name(int rankId) => rankId switch
{
>= 1 and <= 4 => "beginner",
>= 5 and <= 8 => "d",
>= 9 and <= 12 => "c",
>= 13 and <= 16 => "b",
>= 17 and <= 20 => "a",
>= 21 and <= 24 => "aa",
25 => "master",
>= 26 and <= 29 => "grand_master",
_ => null,
};
}

View File

@@ -1,3 +1,5 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Services;
/// <summary>
@@ -109,6 +111,115 @@ public static class MissionEventKeys
};
}
// ---- Class-name mapping (shared by Ranked and ClassLevel families) ----
public static class Class
{
/// <summary>
/// Wire <c>class_id</c> 1-8 → catalog-facing craft name. Ordering matches the
/// <see cref="SVSim.BattleNode.Bridge.CardClass"/> enum (1=Forestcraft ... 8=Portalcraft).
/// Duplicated as a string switch rather than reused from the enum's <c>ToString()</c>
/// because the catalog uses lowercase — an accidental case change would silently break
/// counter alignment.
/// </summary>
public static string? Name(int classId) => classId switch
{
1 => "forestcraft",
2 => "swordcraft",
3 => "runecraft",
4 => "dragoncraft",
5 => "shadowcraft",
6 => "bloodcraft",
7 => "havencraft",
8 => "portalcraft",
_ => null,
};
}
// ---- Ranked / Free / Challenge hierarchical builders ----
public static class Ranked
{
/// <summary>
/// Rank-battle win: emits <c>ranked_win</c>, the class-qualified variant, and the two
/// aggregate keys (<c>ranked_or_arena_win</c>, <c>daily_match_win</c>) that every
/// ranked/arena win advances.
/// </summary>
public static IReadOnlyList<string> WinAll(int classId)
{
var list = new List<string>(4)
{
RankedWin,
RankedOrArenaWin,
DailyMatchWin,
};
if (Class.Name(classId) is { } name)
list.Add($"{RankedWin}:{name}");
return list;
}
}
public static class Free
{
/// <summary>
/// Unranked/free-battle win: only the two aggregates. There is no <c>free_win</c>
/// catalog prefix — free battles just count toward "any match" mission lines.
/// </summary>
public static IReadOnlyList<string> WinAll() => new[] { RankedOrArenaWin, DailyMatchWin };
}
public static class Challenge
{
/// <summary>Any TK2 match finish (win OR loss). Advances <c>challenge_play</c>.</summary>
public static IReadOnlyList<string> MatchPlayAll() => new[] { ChallengePlay };
/// <summary>
/// TK2 match win: fires both <c>challenge_win</c> and <c>challenge_play</c> (a win is
/// also a play) plus the two aggregates. Keeping <c>challenge_play</c> in this list
/// makes the invariant "always increment play, additionally increment win" obvious.
/// </summary>
public static IReadOnlyList<string> MatchWinAll() => new[]
{
ChallengeWin, ChallengePlay, RankedOrArenaWin, DailyMatchWin,
};
/// <summary>TK2 run ended with all 5 wins — advances <c>challenge_full_clear</c>.</summary>
public static IReadOnlyList<string> FullClearAll() => new[] { ChallengeFullClear };
}
// ---- ClassLevel / Rank hierarchical builders ----
public static class ClassLevel
{
/// <summary>
/// Class went up at least one level in this battle. Callsite must gate on
/// <c>BattleXpGrantResult.LeveledUp</c> — this method doesn't check.
/// </summary>
public static IReadOnlyList<string> UpAll(int classId)
{
var list = new List<string>(2) { ClassLevelUp };
if (Class.Name(classId) is { } name)
list.Add($"{ClassLevelUp}:{name}");
return list;
}
}
public static class Rank
{
/// <summary>
/// Viewer's rank crossed into a new tier. Callsite must gate on
/// <c>RankProgressResult.TierAdvanced</c>. Tier name comes from
/// <see cref="RankTier.Name(int)"/>.
/// </summary>
public static IReadOnlyList<string> AchievedAll(int rankId)
{
var list = new List<string>(2) { RankAchieved };
if (RankTier.Name(rankId) is { } name)
list.Add($"{RankAchieved}:{name}");
return list;
}
}
// ---- Seed-import validation ----
private static readonly IReadOnlySet<string> _registeredPrefixes = new HashSet<string>

View File

@@ -1,3 +1,4 @@
using SVSim.Database.Enums;
using SVSim.Database.Services;
namespace SVSim.UnitTests.Services;
@@ -112,6 +113,139 @@ public class MissionEventKeysTests
// ---- Seed drift sanity check ----
// ---- Class name mapping ----
[Test]
public void Class_Name_covers_all_eight_classes()
{
Assert.Multiple(() =>
{
Assert.That(MissionEventKeys.Class.Name(1), Is.EqualTo("forestcraft"));
Assert.That(MissionEventKeys.Class.Name(2), Is.EqualTo("swordcraft"));
Assert.That(MissionEventKeys.Class.Name(3), Is.EqualTo("runecraft"));
Assert.That(MissionEventKeys.Class.Name(4), Is.EqualTo("dragoncraft"));
Assert.That(MissionEventKeys.Class.Name(5), Is.EqualTo("shadowcraft"));
Assert.That(MissionEventKeys.Class.Name(6), Is.EqualTo("bloodcraft"));
Assert.That(MissionEventKeys.Class.Name(7), Is.EqualTo("havencraft"));
Assert.That(MissionEventKeys.Class.Name(8), Is.EqualTo("portalcraft"));
Assert.That(MissionEventKeys.Class.Name(0), Is.Null);
Assert.That(MissionEventKeys.Class.Name(9), Is.Null);
});
}
// ---- Ranked / Free / Challenge ----
[Test]
public void Ranked_WinAll_emits_family_class_and_aggregates()
{
var keys = MissionEventKeys.Ranked.WinAll(classId: 1);
Assert.That(keys, Is.EquivalentTo(new[]
{
"ranked_win",
"ranked_win:forestcraft",
"ranked_or_arena_win",
"daily_match_win",
}));
}
[Test]
public void Ranked_WinAll_drops_class_variant_for_unknown_class()
{
var keys = MissionEventKeys.Ranked.WinAll(classId: 99);
Assert.That(keys, Is.EquivalentTo(new[]
{
"ranked_win", "ranked_or_arena_win", "daily_match_win",
}));
}
[Test]
public void Free_WinAll_emits_only_aggregates()
{
Assert.That(MissionEventKeys.Free.WinAll(),
Is.EquivalentTo(new[] { "ranked_or_arena_win", "daily_match_win" }));
}
[Test]
public void Challenge_MatchPlayAll_top_level_only()
{
Assert.That(MissionEventKeys.Challenge.MatchPlayAll(),
Is.EquivalentTo(new[] { "challenge_play" }));
}
[Test]
public void Challenge_MatchWinAll_includes_play_win_and_aggregates()
{
Assert.That(MissionEventKeys.Challenge.MatchWinAll(), Is.EquivalentTo(new[]
{
"challenge_win", "challenge_play", "ranked_or_arena_win", "daily_match_win",
}));
}
[Test]
public void Challenge_FullClearAll_top_level_only()
{
Assert.That(MissionEventKeys.Challenge.FullClearAll(),
Is.EquivalentTo(new[] { "challenge_full_clear" }));
}
// ---- ClassLevel ----
[Test]
public void ClassLevel_UpAll_emits_family_and_class_variant()
{
var keys = MissionEventKeys.ClassLevel.UpAll(classId: 3);
Assert.That(keys, Is.EquivalentTo(new[] { "class_level_up", "class_level_up:runecraft" }));
}
[Test]
public void ClassLevel_UpAll_drops_class_variant_for_unknown_class()
{
Assert.That(MissionEventKeys.ClassLevel.UpAll(classId: 0),
Is.EquivalentTo(new[] { "class_level_up" }));
}
// ---- Rank ----
[Test]
public void Rank_AchievedAll_maps_all_seven_catalog_tiers()
{
Assert.Multiple(() =>
{
Assert.That(MissionEventKeys.Rank.AchievedAll(1), Contains.Item("rank_achieved:beginner"));
Assert.That(MissionEventKeys.Rank.AchievedAll(5), Contains.Item("rank_achieved:d"));
Assert.That(MissionEventKeys.Rank.AchievedAll(9), Contains.Item("rank_achieved:c"));
Assert.That(MissionEventKeys.Rank.AchievedAll(13), Contains.Item("rank_achieved:b"));
Assert.That(MissionEventKeys.Rank.AchievedAll(17), Contains.Item("rank_achieved:a"));
Assert.That(MissionEventKeys.Rank.AchievedAll(21), Contains.Item("rank_achieved:aa"));
Assert.That(MissionEventKeys.Rank.AchievedAll(25), Contains.Item("rank_achieved:master"));
Assert.That(MissionEventKeys.Rank.AchievedAll(26), Contains.Item("rank_achieved:grand_master"));
});
}
[Test]
public void Rank_AchievedAll_boundary_edges()
{
// Rank 4 = last Beginner slot; rank 5 = first D slot.
Assert.That(RankTier.Name(4), Is.EqualTo("beginner"));
Assert.That(RankTier.Name(5), Is.EqualTo("d"));
// Rank 24 = last AA; rank 25 = Master; rank 26 = first Grand Master.
Assert.That(RankTier.Name(24), Is.EqualTo("aa"));
Assert.That(RankTier.Name(25), Is.EqualTo("master"));
Assert.That(RankTier.Name(26), Is.EqualTo("grand_master"));
// Out of range
Assert.That(RankTier.Name(0), Is.Null);
Assert.That(RankTier.Name(30), Is.Null);
}
[Test]
public void Rank_AchievedAll_drops_tier_variant_for_out_of_range_rank()
{
Assert.That(MissionEventKeys.Rank.AchievedAll(0),
Is.EquivalentTo(new[] { "rank_achieved" }));
Assert.That(MissionEventKeys.Rank.AchievedAll(30),
Is.EquivalentTo(new[] { "rank_achieved" }));
}
[Test]
public void All_seed_prefixes_are_registered()
{