From a55187e10e9979060f9952f373ee5b2b67a4d3cd Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 01:15:41 -0400 Subject: [PATCH] feat(matching): IBotRoster + hardcoded BotRoster fixture (8 bots, one per class) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIBotProfile carries the cosmetic metadata the AI rank-battle start endpoint composes into oppo_info. BotRoster.Pick is deterministic per MatchContext so mid-flight retries get the same opponent. ai_id values 4001..4008 are placeholders per the existing ai-start.md TODO — we have no live capture of the prod catalog. Future improvement: migrate Roster to a bot-roster.json seed under SVSim.Bootstrap/Data/seeds/ for editability without rebuilds. Co-Authored-By: Claude Opus 4.7 --- .../Matching/AIBotProfile.cs | 23 ++++++++ .../Matching/BotRoster.cs | 55 +++++++++++++++++++ .../Matching/IBotRoster.cs | 21 +++++++ SVSim.EmulatedEntrypoint/Program.cs | 2 + SVSim.UnitTests/Matching/BotRosterTests.cs | 48 ++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs create mode 100644 SVSim.EmulatedEntrypoint/Matching/BotRoster.cs create mode 100644 SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs create mode 100644 SVSim.UnitTests/Matching/BotRosterTests.cs diff --git a/SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs b/SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs new file mode 100644 index 0000000..70b2ee8 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs @@ -0,0 +1,23 @@ +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// Cosmetic + identity metadata for an AI opponent. Used to compose +/// oppo_info in the /ai_<fmt>_rank_battle/start response. +/// The wire keys are camelCase (sleeveId, emblemId, etc.) — the DTO handles +/// the JSON serialization; this record is the internal-facing shape. +/// +public sealed record AIBotProfile( + int AiId, + string CountryCode, + string UserName, + int SleeveId, + int EmblemId, + int DegreeId, + int FieldId, + int IsOfficial, + int ClassId, + int CharaId, + int Rank, + int BattlePoint, + int IsMasterRank, + int MasterPoint); diff --git a/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs b/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs new file mode 100644 index 0000000..486f08e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs @@ -0,0 +1,55 @@ +using SVSim.BattleNode.Bridge; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// Phase 3 hardcoded fixture: one stub bot per class (1..8). ai_id +/// values 4001..4008 are placeholders per the existing +/// docs/api-spec/endpoints/post-login/rank-battle/ai-start.md TODO — +/// no live capture exists to validate the real prod catalog. +/// +public sealed class BotRoster : IBotRoster +{ + // Cosmetic ids (sleeve / emblem / degree / field) intentionally use safe + // default values that match the master tables shipped in the project. + // The client-side AI catalog reads ai_id but renders cosmetics from the + // sleeveId/emblemId/etc returned here. + private static readonly IReadOnlyList Roster = new[] + { + new AIBotProfile(AiId: 4001, CountryCode: "NONE", UserName: "Forestcraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 1, CharaId: 1, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4002, CountryCode: "NONE", UserName: "Swordcraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 2, CharaId: 2, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4003, CountryCode: "NONE", UserName: "Runecraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 3, CharaId: 3, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4004, CountryCode: "NONE", UserName: "Dragoncraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 4, CharaId: 4, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4005, CountryCode: "NONE", UserName: "Shadowcraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 5, CharaId: 5, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4006, CountryCode: "NONE", UserName: "Bloodcraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 6, CharaId: 6, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4007, CountryCode: "NONE", UserName: "Havencraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 7, CharaId: 7, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + new AIBotProfile(AiId: 4008, CountryCode: "NONE", UserName: "Portalcraft AI", + SleeveId: 1001, EmblemId: 1, DegreeId: 1, FieldId: 1, IsOfficial: 0, + ClassId: 8, CharaId: 8, Rank: 10, BattlePoint: 0, IsMasterRank: 0, MasterPoint: 0), + }; + + public AIBotProfile Pick(MatchContext selfCtx) + { + // Deterministic: hash the ctx and pick from the roster. Same ctx → + // same bot so a mid-flight retry of /ai_/start returns the same + // opponent (no fresh roster pick on each call). + var hash = StringComparer.Ordinal.GetHashCode(selfCtx.UserName) + ^ StringComparer.Ordinal.GetHashCode(selfCtx.ClassId); + var index = (int)((uint)hash % Roster.Count); + return Roster[index]; + } +} diff --git a/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs b/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs new file mode 100644 index 0000000..e5348ad --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs @@ -0,0 +1,21 @@ +using SVSim.BattleNode.Bridge; + +namespace SVSim.EmulatedEntrypoint.Matching; + +/// +/// Picks a bot opponent for an incoming AI rank battle. Used by +/// RankBattleController.AiStart to compose oppo_info. +/// +/// +/// Phase 3 ships a hardcoded fixture; future improvement is to migrate the roster +/// to SVSim.Bootstrap/Data/seeds/bot-roster.json for editability without +/// rebuilds. See spec § Future improvements. +/// +public interface IBotRoster +{ + /// + /// Returns a bot profile for the calling viewer. Deterministic per + /// — the same context value returns the same bot. + /// + AIBotProfile Pick(MatchContext selfCtx); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 2c927e7..0824e34 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -138,6 +138,8 @@ public class Program new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), })); builder.Services.AddSingleton(); + // Phase 3: bot fixture used by RankBattleController.AiStart to compose oppo_info. + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/SVSim.UnitTests/Matching/BotRosterTests.cs b/SVSim.UnitTests/Matching/BotRosterTests.cs new file mode 100644 index 0000000..7efee8d --- /dev/null +++ b/SVSim.UnitTests/Matching/BotRosterTests.cs @@ -0,0 +1,48 @@ +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.EmulatedEntrypoint.Matching; + +namespace SVSim.UnitTests.Matching; + +[TestFixture] +public class BotRosterTests +{ + private static MatchContext Ctx(string userName, string classId) => new( + SelfDeckCardIds: Array.Empty(), + ClassId: classId, CharaId: classId, CardMasterName: "card_master_node_10015", + CountryCode: "JP", UserName: userName, SleeveId: "0", + EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11); + + [Test] + public void Pick_returns_a_bot_with_valid_ai_id() + { + var roster = new BotRoster(); + var bot = roster.Pick(Ctx("PlayerA", "1")); + + Assert.That(bot.AiId, Is.InRange(4001, 4008)); + } + + [Test] + public void Pick_returns_bot_with_class_metadata_set() + { + var roster = new BotRoster(); + var bot = roster.Pick(Ctx("PlayerA", "1")); + + Assert.That(bot.ClassId, Is.InRange(1, 8)); + Assert.That(bot.CharaId, Is.InRange(1, 8)); + Assert.That(bot.UserName, Is.Not.Null.And.Not.Empty); + Assert.That(bot.CountryCode, Is.EqualTo("NONE")); + } + + [Test] + public void Pick_is_deterministic_per_match_context() + { + var roster = new BotRoster(); + var ctx = Ctx("PlayerA", "3"); + + var a = roster.Pick(ctx); + var b = roster.Pick(ctx); + + Assert.That(a, Is.EqualTo(b), "Same ctx → same bot, so mid-flight retries get the same opponent."); + } +}