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.");
+ }
+}