test: improve blackjack play tests and logging

Refactor `RunRound` to `RunUntilDone` to allow for multi-round
simulations
until a win/loss condition is met. Added round summaries, win/loss/push
tracking, and improved log formatting to provide better visibility into
agent performance.
This commit is contained in:
hypercross 2026-07-20 17:37:23 +08:00
parent f8d0fe314c
commit 10c4caed90
2 changed files with 103 additions and 28 deletions

View File

@ -8,6 +8,9 @@ namespace Game.Blackjack.Tests;
public class PlayTests
{
private const int StartingChips = 100;
private const int BetAmount = 10;
[Fact]
public void Play_BasicStrategy()
{
@ -25,10 +28,10 @@ public class PlayTests
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
log.Header = "Blackjack: Basic Strategy (seed=42)";
var agent = new BasicStrategyAgent();
RunRound(world, group, agent, log);
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
@ -53,10 +56,10 @@ public class PlayTests
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
log.Header = "Blackjack: Random Agent (seed=123)";
var agent = new RandomBlackjackAgent();
RunRound(world, group, agent, log);
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
@ -85,10 +88,10 @@ public class PlayTests
pool.Add(new BasicStrategyAgent(), 7);
pool.Add(new RandomBlackjackAgent(), 3);
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={pool.LastPicked})";
var agent = pool.Pick();
RunRound(world, group, agent, log);
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
@ -101,6 +104,7 @@ public class PlayTests
private sealed class PlayLog
{
public string Header { get; set; } = "";
public readonly List<string> RoundSummaries = new();
public readonly List<string> Decisions = new();
public readonly List<string> Reactivity = new();
public string FinalSnapshot { get; set; } = "";
@ -111,6 +115,10 @@ public class PlayTests
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
sb.AppendLine("--- Round Summaries ---");
for (int i = 0; i < RoundSummaries.Count; i++)
sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}");
sb.AppendLine();
sb.AppendLine("--- Decisions ---");
for (int i = 0; i < Decisions.Count; i++)
sb.AppendLine($" {i + 1}. {Decisions[i]}");
@ -140,7 +148,7 @@ public class PlayTests
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
Chips = StartingChips,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
@ -150,27 +158,96 @@ public class PlayTests
return (world, group);
}
private static void RunRound(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
/// <summary>
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
/// or doubles their starting chips.
/// </summary>
private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
{
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
int totalRounds = 0;
int wins = 0;
int losses = 0;
int pushes = 0;
while (true)
{
var state = world.ReadSingleton<GameState>();
int chips = state.Chips;
// Stop conditions.
if (chips < BetAmount)
{
log.Header += $" — BUSTED after {totalRounds} rounds";
break;
}
if (chips >= StartingChips * 2)
{
log.Header += $" — DOUBLED after {totalRounds} rounds";
break;
}
// Safety: max 200 rounds to prevent infinite loops.
if (totalRounds >= 200)
{
log.Header += $" — MAX ROUNDS ({totalRounds})";
break;
}
// Place bet and run dealing.
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
group.RunLogical();
// Player turn: agent decides hit/stand.
int roundHits = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{
var total = HandUtil.CalculateHand(world, new PlayerHand());
var decision = agent.Decide(world);
log.Decisions.Add($"Hand={total}, Decision={decision}");
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
if (decision == BlackjackDecision.Hit)
{
world.Commands.Enqueue(new HitCommand());
roundHits++;
}
else
{
world.Commands.Enqueue(new StandCommand());
}
group.RunLogical();
}
var state = world.ReadSingleton<GameState>();
log.Header += $" — Result: {state.Result}";
// Record round result.
state = world.ReadSingleton<GameState>();
int newChips = state.Chips;
int delta = newChips - chips;
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}";
log.RoundSummaries.Add(summary);
switch (state.Result)
{
case RoundResult.PlayerWin:
case RoundResult.DealerBust:
wins++;
break;
case RoundResult.DealerWin:
case RoundResult.PlayerBust:
losses++;
break;
case RoundResult.Push:
pushes++;
break;
}
totalRounds++;
// Start next round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
}
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
}
// ── Snapshot ──────────────────────────────────────────────────────
@ -181,6 +258,7 @@ public class PlayTests
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
var playerHand = FindEntity<PlayerHand>(world);
@ -237,6 +315,7 @@ public class PlayTests
{
var content = File.ReadAllText(path);
content.Should().Contain("--- Round Summaries ---");
content.Should().Contain("--- Decisions ---");
content.Should().Contain("--- Reactivity ---");
content.Should().Contain("--- Final State ---");
@ -279,7 +358,6 @@ public class PlayTests
private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new();
private int _totalWeight;
private static readonly Random _rng = new();
public string LastPicked { get; private set; } = "";
public void Add(IBlackjackAgent agent, int weight)
{
@ -295,12 +373,8 @@ public class PlayTests
{
cumulative += weight;
if (roll < cumulative)
{
LastPicked = agent.ToString()!;
return agent;
}
}
LastPicked = _agents[^1].Agent.ToString()!;
return _agents[^1].Agent;
}
}

View File

@ -46,6 +46,7 @@ public struct NewRoundCommand : ICommand
}
}
state.RoundNumber++;
state.Phase = GamePhase.Betting;
state.Result = RoundResult.None;
world.MarkModified<GameState>(World.SingletonEntity);