feat(mission): add MissionEventKeys registry with Practice numeric→named mapping

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 12:59:15 -04:00
parent 6e6856db4f
commit e1b38d5649
2 changed files with 274 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
namespace SVSim.Database.Services;
/// <summary>
/// Registry of every string that can appear as <c>ViewerEventCounter.EventKey</c> or
/// mission/achievement <c>EventType</c>. Two goals:
///
/// <list type="number">
/// <item>Give emitters (controllers) a single named entry point per event family so no
/// controller inlines the string form.</item>
/// <item>Give the catalog seed importers a validation set — every <c>event_type</c> 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 <c>practice_wln</c> and never advances).</item>
/// </list>
///
/// Format convention: colon-hierarchical, most-general first. Callers emit multiple levels
/// so a single event increments every level of counter the catalog might reference.
/// </summary>
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
{
/// <summary>
/// Wire <c>difficulty</c> → 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."
/// </summary>
public static string? TierName(int wireDifficulty) => wireDifficulty switch
{
4 => "elite",
6 => "elite2",
7 => "elite3",
_ => null,
};
/// <summary>
/// Wire <c>enemy_class_id</c> → leader name used in catalog rows. Class ordering
/// matches the <c>CardClass</c> enum (1=Forestcraft/Arisa, ..., 8=Portalcraft/Yuwan).
/// </summary>
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,
};
/// <summary>
/// Emits <c>practice_win</c> 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.
/// </summary>
public static IReadOnlyList<string> WinAll(int wireDifficulty, int enemyClassId)
{
var tier = TierName(wireDifficulty);
var leader = LeaderName(enemyClassId);
var list = new List<string>(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
{
/// <summary>
/// Emits <c>story_chapter_finish</c> plus <c>:{family}</c> plus <c>:{family}:{storyId}</c>.
/// <paramref name="family"/> is the low-cardinality family label (<c>main</c>,
/// <c>limited</c>, <c>event</c>, ...) resolved from <c>StoryApiType</c> upstream.
/// </summary>
public static IReadOnlyList<string> ChapterFinishAll(string family, long storyId) => new[]
{
StoryChapterFinish,
$"{StoryChapterFinish}:{family}",
$"{StoryChapterFinish}:{family}:{storyId}",
};
}
// ---- Seed-import validation ----
private static readonly IReadOnlySet<string> _registeredPrefixes = new HashSet<string>
{
PracticeWin, RankedWin, RankedOrArenaWin, DailyMatchWin, StoryChapterFinish,
ClassLevelUp, RankAchieved, ChallengePlay, ChallengeWin, ChallengeFullClear,
PlayFollowers, PrivateMatchDistinctOpponent,
ItemPurchasePrefix,
};
/// <summary>
/// True iff <paramref name="eventType"/> is a registered top-level prefix or a hierarchical
/// extension of one (<c>prefix</c> alone, or <c>prefix:qualifier</c>). Called by the
/// achievement/mission catalog importers to catch drift between seed data and code.
/// </summary>
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;
}
/// <summary>Exposed for test assertions; do NOT mutate.</summary>
public static IReadOnlySet<string> RegisteredPrefixes => _registeredPrefixes;
}