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:
142
SVSim.Database/Services/MissionEventKeys.cs
Normal file
142
SVSim.Database/Services/MissionEventKeys.cs
Normal 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;
|
||||
}
|
||||
132
SVSim.UnitTests/Services/MissionEventKeysTests.cs
Normal file
132
SVSim.UnitTests/Services/MissionEventKeysTests.cs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user