feat(matching): IBotRoster + hardcoded BotRoster fixture (8 bots, one per class)

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 01:15:41 -04:00
parent 7eaf13893e
commit a55187e10e
5 changed files with 149 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
namespace SVSim.EmulatedEntrypoint.Matching;
/// <summary>
/// Cosmetic + identity metadata for an AI opponent. Used to compose
/// <c>oppo_info</c> in the <c>/ai_&lt;fmt&gt;_rank_battle/start</c> response.
/// The wire keys are camelCase (sleeveId, emblemId, etc.) — the DTO handles
/// the JSON serialization; this record is the internal-facing shape.
/// </summary>
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);

View File

@@ -0,0 +1,55 @@
using SVSim.BattleNode.Bridge;
namespace SVSim.EmulatedEntrypoint.Matching;
/// <summary>
/// Phase 3 hardcoded fixture: one stub bot per class (1..8). <c>ai_id</c>
/// values 4001..4008 are placeholders per the existing
/// <c>docs/api-spec/endpoints/post-login/rank-battle/ai-start.md</c> TODO —
/// no live capture exists to validate the real prod catalog.
/// </summary>
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<AIBotProfile> 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_<fmt>/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];
}
}

View File

@@ -0,0 +1,21 @@
using SVSim.BattleNode.Bridge;
namespace SVSim.EmulatedEntrypoint.Matching;
/// <summary>
/// Picks a bot opponent for an incoming AI rank battle. Used by
/// <c>RankBattleController.AiStart</c> to compose <c>oppo_info</c>.
/// </summary>
/// <remarks>
/// Phase 3 ships a hardcoded fixture; future improvement is to migrate the roster
/// to <c>SVSim.Bootstrap/Data/seeds/bot-roster.json</c> for editability without
/// rebuilds. See spec § Future improvements.
/// </remarks>
public interface IBotRoster
{
/// <summary>
/// Returns a bot profile for the calling viewer. Deterministic per
/// <see cref="MatchContext"/> — the same context value returns the same bot.
/// </summary>
AIBotProfile Pick(MatchContext selfCtx);
}

View File

@@ -138,6 +138,8 @@ public class Program
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
}));
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
// Phase 3: bot fixture used by RankBattleController.AiStart to compose oppo_info.
builder.Services.AddSingleton<IBotRoster, BotRoster>();
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
builder.Services.AddTransient<SessionidMappingMiddleware>();

View File

@@ -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<long>(),
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.");
}
}