diff --git a/SVSim.Database/Enums/RankTier.cs b/SVSim.Database/Enums/RankTier.cs
new file mode 100644
index 00000000..55e6f642
--- /dev/null
+++ b/SVSim.Database/Enums/RankTier.cs
@@ -0,0 +1,36 @@
+namespace SVSim.Database.Enums;
+
+///
+/// Rank-tier bucket names used by mission/achievement catalog rows keyed as
+/// rank_achieved:{tier}. Boundaries pinned from ranks.csv:
+///
+/// - 1-4 = Beginner 0-3
+/// - 5-8 = D0-D3
+/// - 9-12 = C0-C3
+/// - 13-16 = B0-B3
+/// - 17-20 = A0-A3
+/// - 21-24 = AA0-AA3
+/// - 25 = Master
+/// - 26-29 = Grand Master (G026-G029)
+///
+/// The current prod catalog has no rank_achieved:grand_master rows, but the
+/// emit is still faithful — a grand-master promotion advances the top-level
+/// rank_achieved counter and the tier-qualified one (which no catalog row
+/// reads yet, but stays consistent if one lands later).
+///
+public static class RankTier
+{
+ /// Wire rank_id → catalog-facing tier name. Null iff is out of range.
+ 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,
+ };
+}
diff --git a/SVSim.Database/Services/MissionEventKeys.cs b/SVSim.Database/Services/MissionEventKeys.cs
index a2df184d..14d7d34e 100644
--- a/SVSim.Database/Services/MissionEventKeys.cs
+++ b/SVSim.Database/Services/MissionEventKeys.cs
@@ -1,3 +1,5 @@
+using SVSim.Database.Enums;
+
namespace SVSim.Database.Services;
///
@@ -109,6 +111,115 @@ public static class MissionEventKeys
};
}
+ // ---- Class-name mapping (shared by Ranked and ClassLevel families) ----
+
+ public static class Class
+ {
+ ///
+ /// Wire class_id 1-8 → catalog-facing craft name. Ordering matches the
+ /// enum (1=Forestcraft ... 8=Portalcraft).
+ /// Duplicated as a string switch rather than reused from the enum's ToString()
+ /// because the catalog uses lowercase — an accidental case change would silently break
+ /// counter alignment.
+ ///
+ 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
+ {
+ ///
+ /// Rank-battle win: emits ranked_win, the class-qualified variant, and the two
+ /// aggregate keys (ranked_or_arena_win, daily_match_win) that every
+ /// ranked/arena win advances.
+ ///
+ public static IReadOnlyList WinAll(int classId)
+ {
+ var list = new List(4)
+ {
+ RankedWin,
+ RankedOrArenaWin,
+ DailyMatchWin,
+ };
+ if (Class.Name(classId) is { } name)
+ list.Add($"{RankedWin}:{name}");
+ return list;
+ }
+ }
+
+ public static class Free
+ {
+ ///
+ /// Unranked/free-battle win: only the two aggregates. There is no free_win
+ /// catalog prefix — free battles just count toward "any match" mission lines.
+ ///
+ public static IReadOnlyList WinAll() => new[] { RankedOrArenaWin, DailyMatchWin };
+ }
+
+ public static class Challenge
+ {
+ /// Any TK2 match finish (win OR loss). Advances challenge_play.
+ public static IReadOnlyList MatchPlayAll() => new[] { ChallengePlay };
+
+ ///
+ /// TK2 match win: fires both challenge_win and challenge_play (a win is
+ /// also a play) plus the two aggregates. Keeping challenge_play in this list
+ /// makes the invariant "always increment play, additionally increment win" obvious.
+ ///
+ public static IReadOnlyList MatchWinAll() => new[]
+ {
+ ChallengeWin, ChallengePlay, RankedOrArenaWin, DailyMatchWin,
+ };
+
+ /// TK2 run ended with all 5 wins — advances challenge_full_clear.
+ public static IReadOnlyList FullClearAll() => new[] { ChallengeFullClear };
+ }
+
+ // ---- ClassLevel / Rank hierarchical builders ----
+
+ public static class ClassLevel
+ {
+ ///
+ /// Class went up at least one level in this battle. Callsite must gate on
+ /// BattleXpGrantResult.LeveledUp — this method doesn't check.
+ ///
+ public static IReadOnlyList UpAll(int classId)
+ {
+ var list = new List(2) { ClassLevelUp };
+ if (Class.Name(classId) is { } name)
+ list.Add($"{ClassLevelUp}:{name}");
+ return list;
+ }
+ }
+
+ public static class Rank
+ {
+ ///
+ /// Viewer's rank crossed into a new tier. Callsite must gate on
+ /// RankProgressResult.TierAdvanced. Tier name comes from
+ /// .
+ ///
+ public static IReadOnlyList AchievedAll(int rankId)
+ {
+ var list = new List(2) { RankAchieved };
+ if (RankTier.Name(rankId) is { } name)
+ list.Add($"{RankAchieved}:{name}");
+ return list;
+ }
+ }
+
// ---- Seed-import validation ----
private static readonly IReadOnlySet _registeredPrefixes = new HashSet
diff --git a/SVSim.UnitTests/Services/MissionEventKeysTests.cs b/SVSim.UnitTests/Services/MissionEventKeysTests.cs
index 9a11d1f2..974f4294 100644
--- a/SVSim.UnitTests/Services/MissionEventKeysTests.cs
+++ b/SVSim.UnitTests/Services/MissionEventKeysTests.cs
@@ -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()
{