fix(ranked-ai): randomize bot selection and seed for AI fallback matches
Bot roster pick was hashing (UserName, ClassId) — same player always faced the same bot class. Now hashes battleId so different matches get different opponents while retries of the same pending battle stay consistent. AI start response hardcoded Seed=0 for both sides, so the client's deck shuffle/mulligan/draw RNG was deterministic every match. The BattleNode's per-battle MasterSeed (Random.Shared) was never sent to bot-mode clients because InitBattleHandler skips the Matched frame. Now populates Seed with Random.Shared.Next() on the HTTP response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class BotRoster : IBotRoster
|
||||
_globals = globals;
|
||||
}
|
||||
|
||||
public async Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default)
|
||||
public async Task<AIBotProfile> 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_<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);
|
||||
// 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);
|
||||
|
||||
@@ -14,9 +14,9 @@ namespace SVSim.EmulatedEntrypoint.Matching;
|
||||
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, so a
|
||||
/// mid-flight retry of <c>/ai_<fmt>/start</c> picks the same opponent.
|
||||
/// Returns a bot profile. Deterministic per <paramref name="battleId"/> so a
|
||||
/// mid-flight retry of <c>/ai_<fmt>/start</c> picks the same opponent,
|
||||
/// but different battles get different bots.
|
||||
/// </summary>
|
||||
Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default);
|
||||
Task<AIBotProfile> PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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<int>();
|
||||
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<IGlobalsRepository>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user