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;
}