From e1b38d5649bb0f2513cec2200d67d65487103401 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 12:59:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(mission):=20add=20MissionEventKeys=20regis?= =?UTF-8?q?try=20with=20Practice=20numeric=E2=86=92named=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Central registry of every string that can appear as ViewerEventCounter.EventKey or catalog EventType. All 12 top-level prefixes from the achievement/mission seed JSON are constants; Practice.WinAll and Story.ChapterFinishAll are hierarchical builders that emit multi-level counter keys per event. Practice.WinAll translates the wire (difficulty:int, enemy_class_id:int) tuple into the catalog-facing named form (elite/elite2/elite3 : arisa/erika/...). Wire difficulty 4/6/7 map to elite/elite2/elite3 (verified via practicetext.json + practice-opponents.json + practice_ai_setting.csv). Class 1-8 map to the leader names in CardClass ordering. Wire values outside the known set skip that hierarchical level but still advance the top-level counter. MissionEventKeys.IsRegistered validates that a string starts with a registered top-level prefix — for seed-importer drift checks in the next commit. Co-Authored-By: Claude Opus 4.7 --- SVSim.Database/Services/MissionEventKeys.cs | 142 ++++++++++++++++++ .../Services/MissionEventKeysTests.cs | 132 ++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 SVSim.Database/Services/MissionEventKeys.cs create mode 100644 SVSim.UnitTests/Services/MissionEventKeysTests.cs 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"); + } + } +}