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() {