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

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