diff --git a/SVSim.Database/Services/MissionEventKeys.cs b/SVSim.Database/Services/MissionEventKeys.cs
new file mode 100644
index 00000000..a2df184d
--- /dev/null
+++ b/SVSim.Database/Services/MissionEventKeys.cs
@@ -0,0 +1,142 @@
+namespace SVSim.Database.Services;
+
+///
+/// Registry of every string that can appear as ViewerEventCounter.EventKey or
+/// mission/achievement EventType. Two goals:
+///
+///
+/// - Give emitters (controllers) a single named entry point per event family so no
+/// controller inlines the string form.
+/// - Give the catalog seed importers a validation set — every event_type in a
+/// seed row must start with a prefix registered here, else the importer throws at bootstrap.
+/// Prevents silent drift between catalog data and emitter code (e.g. a mission that
+/// references practice_wln and never advances).
+///
+///
+/// Format convention: colon-hierarchical, most-general first. Callers emit multiple levels
+/// so a single event increments every level of counter the catalog might reference.
+///
+public static class MissionEventKeys
+{
+ // ---- Top-level catalog prefixes (12) — 1:1 with seed JSON event_type strings ----
+
+ public const string PracticeWin = "practice_win";
+ public const string RankedWin = "ranked_win";
+ public const string RankedOrArenaWin = "ranked_or_arena_win";
+ public const string DailyMatchWin = "daily_match_win";
+ public const string StoryChapterFinish = "story_chapter_finish";
+ public const string ClassLevelUp = "class_level_up";
+ public const string RankAchieved = "rank_achieved";
+ public const string ChallengePlay = "challenge_play";
+ public const string ChallengeWin = "challenge_win";
+ public const string ChallengeFullClear = "challenge_full_clear";
+ public const string PlayFollowers = "play_followers";
+ public const string PrivateMatchDistinctOpponent = "private_match_distinct_opponent";
+
+ // ---- Item purchase (per-catalog-entry) ----
+
+ public const string ItemPurchasePrefix = "item_purchase";
+ public static string ItemPurchase(int catalogId) => $"{ItemPurchasePrefix}:{catalogId}";
+
+ // ---- Practice hierarchical builders ----
+
+ public static class Practice
+ {
+ ///
+ /// Wire difficulty → tier name used in catalog rows. Values 4/6/7 correspond to
+ /// Elite / Elite 2 / Elite 3 respectively (verified via
+ /// practicetext.json + practice-opponents.json + practice_ai_setting.csv cross-reference).
+ /// Other CSV difficulty values (0, 2, 3, 5, 101-109) have no achievement catalog rows —
+ /// null return means "emit only the top-level counter."
+ ///
+ public static string? TierName(int wireDifficulty) => wireDifficulty switch
+ {
+ 4 => "elite",
+ 6 => "elite2",
+ 7 => "elite3",
+ _ => null,
+ };
+
+ ///
+ /// Wire enemy_class_id → leader name used in catalog rows. Class ordering
+ /// matches the CardClass enum (1=Forestcraft/Arisa, ..., 8=Portalcraft/Yuwan).
+ ///
+ public static string? LeaderName(int enemyClassId) => enemyClassId switch
+ {
+ 1 => "arisa",
+ 2 => "erika",
+ 3 => "isabelle",
+ 4 => "rowen",
+ 5 => "luna",
+ 6 => "urias",
+ 7 => "eris",
+ 8 => "yuwan",
+ _ => null,
+ };
+
+ ///
+ /// Emits practice_win plus any hierarchical variants whose parts resolve. If the
+ /// wire values don't resolve to a known tier or leader, that level is skipped — the
+ /// lifetime counter still advances.
+ ///
+ public static IReadOnlyList WinAll(int wireDifficulty, int enemyClassId)
+ {
+ var tier = TierName(wireDifficulty);
+ var leader = LeaderName(enemyClassId);
+ var list = new List(3) { PracticeWin };
+ if (tier is not null)
+ list.Add($"{PracticeWin}:{tier}");
+ if (tier is not null && leader is not null)
+ list.Add($"{PracticeWin}:{tier}:{leader}");
+ return list;
+ }
+ }
+
+ // ---- Story hierarchical builders ----
+
+ public static class Story
+ {
+ ///
+ /// Emits story_chapter_finish plus :{family} plus :{family}:{storyId}.
+ /// is the low-cardinality family label (main,
+ /// limited, event, ...) resolved from StoryApiType upstream.
+ ///
+ public static IReadOnlyList ChapterFinishAll(string family, long storyId) => new[]
+ {
+ StoryChapterFinish,
+ $"{StoryChapterFinish}:{family}",
+ $"{StoryChapterFinish}:{family}:{storyId}",
+ };
+ }
+
+ // ---- Seed-import validation ----
+
+ private static readonly IReadOnlySet _registeredPrefixes = new HashSet
+ {
+ PracticeWin, RankedWin, RankedOrArenaWin, DailyMatchWin, StoryChapterFinish,
+ ClassLevelUp, RankAchieved, ChallengePlay, ChallengeWin, ChallengeFullClear,
+ PlayFollowers, PrivateMatchDistinctOpponent,
+ ItemPurchasePrefix,
+ };
+
+ ///
+ /// True iff is a registered top-level prefix or a hierarchical
+ /// extension of one (prefix alone, or prefix:qualifier). Called by the
+ /// achievement/mission catalog importers to catch drift between seed data and code.
+ ///
+ public static bool IsRegistered(string eventType)
+ {
+ foreach (var prefix in _registeredPrefixes)
+ {
+ if (eventType == prefix) return true;
+ if (eventType.Length > prefix.Length + 1
+ && eventType[prefix.Length] == ':'
+ && eventType.AsSpan(0, prefix.Length).SequenceEqual(prefix))
+ return true;
+ }
+ return false;
+ }
+
+ /// Exposed for test assertions; do NOT mutate.
+ public static IReadOnlySet RegisteredPrefixes => _registeredPrefixes;
+}
diff --git a/SVSim.UnitTests/Services/MissionEventKeysTests.cs b/SVSim.UnitTests/Services/MissionEventKeysTests.cs
new file mode 100644
index 00000000..9a11d1f2
--- /dev/null
+++ b/SVSim.UnitTests/Services/MissionEventKeysTests.cs
@@ -0,0 +1,132 @@
+using SVSim.Database.Services;
+
+namespace SVSim.UnitTests.Services;
+
+public class MissionEventKeysTests
+{
+ // ---- Practice.WinAll ----
+
+ [Test]
+ public void Practice_WinAll_emits_all_three_levels_for_known_tier_and_leader()
+ {
+ // Wire difficulty 4 = "elite", enemy_class_id 1 = "arisa" (Forestcraft).
+ var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 1);
+ Assert.That(keys, Is.EqualTo(new[]
+ {
+ "practice_win",
+ "practice_win:elite",
+ "practice_win:elite:arisa",
+ }));
+ }
+
+ [Test]
+ public void Practice_WinAll_maps_all_three_elite_tiers()
+ {
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
+ Assert.That(MissionEventKeys.Practice.WinAll(6, 2), Contains.Item("practice_win:elite2:erika"));
+ Assert.That(MissionEventKeys.Practice.WinAll(7, 2), Contains.Item("practice_win:elite3:erika"));
+ }
+
+ [Test]
+ public void Practice_WinAll_covers_all_eight_leaders()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 1), Contains.Item("practice_win:elite:arisa"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 3), Contains.Item("practice_win:elite:isabelle"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 4), Contains.Item("practice_win:elite:rowen"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 5), Contains.Item("practice_win:elite:luna"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 6), Contains.Item("practice_win:elite:urias"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 7), Contains.Item("practice_win:elite:eris"));
+ Assert.That(MissionEventKeys.Practice.WinAll(4, 8), Contains.Item("practice_win:elite:yuwan"));
+ });
+ }
+
+ [Test]
+ public void Practice_WinAll_falls_back_to_top_level_for_non_elite_difficulty()
+ {
+ // Wire difficulty 2 = "Advanced" (or similar) — not in the elite tier registry.
+ // Emit only the top-level counter; hierarchical levels drop off.
+ var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 2, enemyClassId: 1);
+ Assert.That(keys, Is.EqualTo(new[] { "practice_win" }));
+ }
+
+ [Test]
+ public void Practice_WinAll_drops_leader_level_for_unknown_class()
+ {
+ var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 99);
+ Assert.That(keys, Is.EqualTo(new[] { "practice_win", "practice_win:elite" }));
+ }
+
+ // ---- Story.ChapterFinishAll ----
+
+ [Test]
+ public void Story_ChapterFinishAll_emits_all_three_levels()
+ {
+ var keys = MissionEventKeys.Story.ChapterFinishAll("main", 42);
+ Assert.That(keys, Is.EqualTo(new[]
+ {
+ "story_chapter_finish",
+ "story_chapter_finish:main",
+ "story_chapter_finish:main:42",
+ }));
+ }
+
+ // ---- ItemPurchase ----
+
+ [Test]
+ public void ItemPurchase_returns_prefixed_id_string()
+ {
+ Assert.That(MissionEventKeys.ItemPurchase(501), Is.EqualTo("item_purchase:501"));
+ }
+
+ // ---- IsRegistered ----
+
+ [Test]
+ public void IsRegistered_accepts_bare_top_level_prefix()
+ {
+ Assert.That(MissionEventKeys.IsRegistered("practice_win"), Is.True);
+ Assert.That(MissionEventKeys.IsRegistered("ranked_or_arena_win"), Is.True);
+ Assert.That(MissionEventKeys.IsRegistered("challenge_full_clear"), Is.True);
+ }
+
+ [Test]
+ public void IsRegistered_accepts_hierarchical_extensions()
+ {
+ Assert.That(MissionEventKeys.IsRegistered("practice_win:elite:arisa"), Is.True);
+ Assert.That(MissionEventKeys.IsRegistered("class_level_up:forestcraft"), Is.True);
+ Assert.That(MissionEventKeys.IsRegistered("item_purchase:501"), Is.True);
+ Assert.That(MissionEventKeys.IsRegistered("rank_achieved:master"), Is.True);
+ }
+
+ [Test]
+ public void IsRegistered_rejects_typos_and_unknown_prefixes()
+ {
+ Assert.That(MissionEventKeys.IsRegistered("practice_wln"), Is.False); // typo
+ Assert.That(MissionEventKeys.IsRegistered("practice_win_extra"), Is.False); // suffix without colon
+ Assert.That(MissionEventKeys.IsRegistered("battle_win_total"), Is.False); // test-only fixture
+ Assert.That(MissionEventKeys.IsRegistered("orphan_event"), Is.False);
+ Assert.That(MissionEventKeys.IsRegistered(""), Is.False);
+ }
+
+ // ---- Seed drift sanity check ----
+
+ [Test]
+ public void All_seed_prefixes_are_registered()
+ {
+ // Every prefix that could appear in a seed row must be in the registry. Enumerating
+ // explicitly rather than reading the seed JSON — the point is to catch someone removing
+ // a prefix from the code without noticing the seed still references it.
+ string[] knownSeedPrefixes = {
+ "practice_win", "ranked_win", "ranked_or_arena_win", "daily_match_win",
+ "story_chapter_finish", "class_level_up", "rank_achieved",
+ "challenge_play", "challenge_win", "challenge_full_clear",
+ "play_followers", "private_match_distinct_opponent",
+ };
+ foreach (var prefix in knownSeedPrefixes)
+ {
+ Assert.That(MissionEventKeys.IsRegistered(prefix), Is.True, $"prefix '{prefix}' not registered");
+ }
+ }
+}