diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 11b036f..f2aaae7 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -162,7 +162,8 @@ public sealed class RankBattleController : ControllerBase } var selfCtx = pending.P1.Context; - var bot = await _botRoster.PickAsync(selfCtx, ct); + var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct); + var seed = Random.Shared.Next(); // Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first). return Ok(new AiBattleStartResponseDto @@ -179,7 +180,7 @@ public sealed class RankBattleController : ControllerBase FieldId = selfCtx.FieldId, IsOfficial = selfCtx.IsOfficial, OppoId = bot.AiId, - Seed = 0, + Seed = seed, Rank = 0, BattlePoint = 0, ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1, @@ -197,7 +198,7 @@ public sealed class RankBattleController : ControllerBase FieldId = bot.FieldId, IsOfficial = bot.IsOfficial, OppoId = (int)vid, - Seed = 0, + Seed = seed, Rank = bot.Rank, BattlePoint = bot.BattlePoint, ClassId = bot.ClassId, diff --git a/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs b/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs index 48bf3d6..e9631a5 100644 --- a/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs +++ b/SVSim.EmulatedEntrypoint/Matching/BotRoster.cs @@ -18,7 +18,7 @@ public sealed class BotRoster : IBotRoster _globals = globals; } - public async Task PickAsync(MatchContext selfCtx, CancellationToken ct = default) + public async Task PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default) { var roster = await _globals.GetBotRoster(); if (roster.Count == 0) @@ -27,11 +27,9 @@ public sealed class BotRoster : IBotRoster "BotRoster is empty. Run SVSim.Bootstrap to import seeds/bot-roster.json."); } - // 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); + // Deterministic per battle ID: same pending battle → same bot on retry, + // but different battles get different opponents. + var hash = StringComparer.Ordinal.GetHashCode(battleId); var index = (int)((uint)hash % roster.Count); var row = roster[index]; return ToProfile(row); diff --git a/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs b/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs index 20149ca..404e7d0 100644 --- a/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs +++ b/SVSim.EmulatedEntrypoint/Matching/IBotRoster.cs @@ -14,9 +14,9 @@ namespace SVSim.EmulatedEntrypoint.Matching; public interface IBotRoster { /// - /// Returns a bot profile for the calling viewer. Deterministic per - /// — the same context value returns the same bot, so a - /// mid-flight retry of /ai_<fmt>/start picks the same opponent. + /// Returns a bot profile. Deterministic per so a + /// mid-flight retry of /ai_<fmt>/start picks the same opponent, + /// but different battles get different bots. /// - Task PickAsync(MatchContext selfCtx, CancellationToken ct = default); + Task PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default); } diff --git a/SVSim.UnitTests/Matching/BotRosterTests.cs b/SVSim.UnitTests/Matching/BotRosterTests.cs index 0d8a269..0f4601e 100644 --- a/SVSim.UnitTests/Matching/BotRosterTests.cs +++ b/SVSim.UnitTests/Matching/BotRosterTests.cs @@ -30,7 +30,7 @@ public class BotRosterTests using var factory = new SVSimTestFactory(); var roster = await NewRosterAsync(factory); - var bot = await roster.PickAsync(Ctx("PlayerA", "1")); + var bot = await roster.PickAsync(Ctx("PlayerA", "1"), "123456789012"); // Series-1 enemy_ai_id values from data_dumps/client-assets/rm_ai_setting.csv — // one per class (1=Forest, 2=Sword, 3=Rune, 4=Dragon, 5=Shadow, 6=Blood, 7=Haven, 8=Portal). @@ -44,7 +44,7 @@ public class BotRosterTests using var factory = new SVSimTestFactory(); var roster = await NewRosterAsync(factory); - var bot = await roster.PickAsync(Ctx("PlayerA", "1")); + var bot = await roster.PickAsync(Ctx("PlayerA", "1"), "123456789012"); Assert.That(bot.ClassId, Is.InRange(1, 8)); Assert.That(bot.CharaId, Is.InRange(1, 8)); @@ -53,16 +53,33 @@ public class BotRosterTests } [Test] - public async Task PickAsync_is_deterministic_per_match_context() + public async Task PickAsync_is_deterministic_per_battle_id() { using var factory = new SVSimTestFactory(); var roster = await NewRosterAsync(factory); var ctx = Ctx("PlayerA", "3"); - var a = await roster.PickAsync(ctx); - var b = await roster.PickAsync(ctx); + var a = await roster.PickAsync(ctx, "999888777666"); + var b = await roster.PickAsync(ctx, "999888777666"); - Assert.That(a, Is.EqualTo(b), "Same ctx → same bot, so mid-flight retries get the same opponent."); + Assert.That(a, Is.EqualTo(b), "Same battleId → same bot, so mid-flight retries get the same opponent."); + } + + [Test] + public async Task PickAsync_varies_across_different_battle_ids() + { + using var factory = new SVSimTestFactory(); + var roster = await NewRosterAsync(factory); + var ctx = Ctx("PlayerA", "3"); + + var seen = new HashSet(); + for (var i = 0; i < 20; i++) + { + var bot = await roster.PickAsync(ctx, $"{100000000000 + i}"); + seen.Add(bot.AiId); + } + + Assert.That(seen.Count, Is.GreaterThan(1), "Different battle IDs should pick different bots."); } [Test] @@ -74,7 +91,7 @@ public class BotRosterTests var globals = scope.ServiceProvider.GetRequiredService(); var roster = new BotRoster(globals); - Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", "1")), + Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", "1"), "000000000001"), Throws.InvalidOperationException); } }