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:
gamer147
2026-06-04 21:49:43 -04:00
parent 77c99cc230
commit e9af7af1b8
4 changed files with 36 additions and 20 deletions

View File

@@ -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);

View File

@@ -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_&lt;fmt&gt;/start</c> picks the same opponent.
/// Returns a bot profile. Deterministic per <paramref name="battleId"/> so a
/// mid-flight retry of <c>/ai_&lt;fmt&gt;/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);
}