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:
23
SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs
Normal file
23
SVSim.EmulatedEntrypoint/Matching/AIBotProfile.cs
Normal 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_<fmt>_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);
|
||||
55
SVSim.EmulatedEntrypoint/Matching/BotRoster.cs
Normal file
55
SVSim.EmulatedEntrypoint/Matching/BotRoster.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
21
SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs
Normal file
21
SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs
Normal 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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
48
SVSim.UnitTests/Matching/BotRosterTests.cs
Normal file
48
SVSim.UnitTests/Matching/BotRosterTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user