using SVSim.Database.Enums; 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}", }; } // ---- 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 { 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; }