2026-07-20 17:22:07 +08:00
|
|
|
using FluentAssertions;
|
|
|
|
|
using Game.Blackjack;
|
|
|
|
|
using OECS;
|
2026-07-21 18:09:47 +08:00
|
|
|
using OECS.PlayTest;
|
2026-07-20 17:22:07 +08:00
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace Game.Blackjack.Tests;
|
|
|
|
|
|
|
|
|
|
public class PlayTests
|
|
|
|
|
{
|
2026-07-20 17:37:23 +08:00
|
|
|
private const int StartingChips = 100;
|
|
|
|
|
private const int BetAmount = 10;
|
|
|
|
|
|
2026-07-20 17:22:07 +08:00
|
|
|
[Fact]
|
|
|
|
|
public void Play_BasicStrategy()
|
|
|
|
|
{
|
2026-07-21 18:09:47 +08:00
|
|
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
2026-07-20 17:22:07 +08:00
|
|
|
var agent = new BasicStrategyAgent();
|
2026-07-20 17:37:23 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var log = RunUntilDone(world, group, agent,
|
|
|
|
|
$"Blackjack: Basic Strategy (seed=42, agent={agent})");
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var path = log.SaveTo("blackjack_basic_strategy.playlog");
|
2026-07-20 17:22:07 +08:00
|
|
|
ReadAndVerify(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Play_RandomAgent()
|
|
|
|
|
{
|
2026-07-21 18:09:47 +08:00
|
|
|
var (world, group) = TestHelpers.SetupGame(seed: 123);
|
2026-07-20 17:22:07 +08:00
|
|
|
var agent = new RandomBlackjackAgent();
|
|
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var log = RunUntilDone(world, group, agent,
|
|
|
|
|
$"Blackjack: Random Agent (seed=123, agent={agent})");
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var path = log.SaveTo("blackjack_random_agent.playlog");
|
2026-07-20 17:22:07 +08:00
|
|
|
ReadAndVerify(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Play_WeightedPool()
|
|
|
|
|
{
|
2026-07-21 18:09:47 +08:00
|
|
|
var (world, group) = TestHelpers.SetupGame(seed: 77);
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var pool = new WeightedAgentPool<BlackjackDecision>();
|
2026-07-20 17:22:07 +08:00
|
|
|
pool.Add(new BasicStrategyAgent(), 7);
|
|
|
|
|
pool.Add(new RandomBlackjackAgent(), 3);
|
|
|
|
|
|
|
|
|
|
var agent = pool.Pick();
|
2026-07-21 18:09:47 +08:00
|
|
|
var log = RunUntilDone(world, group, agent,
|
|
|
|
|
$"Blackjack: Weighted Pool (seed=77, agent={agent})");
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
var path = log.SaveTo("blackjack_weighted_pool.playlog");
|
2026-07-20 17:22:07 +08:00
|
|
|
ReadAndVerify(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Round Runner ──────────────────────────────────────────────────
|
|
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
/// <summary>
|
|
|
|
|
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
|
|
|
|
|
/// or doubles their starting chips.
|
|
|
|
|
/// </summary>
|
2026-07-21 18:09:47 +08:00
|
|
|
private static PlayLog RunUntilDone(World world, SystemGroup group,
|
|
|
|
|
IAgent<BlackjackDecision> agent, string header)
|
2026-07-20 17:22:07 +08:00
|
|
|
{
|
2026-07-21 18:09:47 +08:00
|
|
|
var log = new PlayLog { Header = header };
|
|
|
|
|
|
|
|
|
|
using var capture = new ObservableCapture(world);
|
|
|
|
|
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
|
|
|
|
|
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet}");
|
|
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
int totalRounds = 0;
|
|
|
|
|
int wins = 0;
|
|
|
|
|
int losses = 0;
|
|
|
|
|
int pushes = 0;
|
2026-07-21 18:09:47 +08:00
|
|
|
var roundSummaries = new List<string>();
|
|
|
|
|
var decisions = new List<string>();
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
while (true)
|
2026-07-20 17:22:07 +08:00
|
|
|
{
|
2026-07-20 17:37:23 +08:00
|
|
|
var state = world.ReadSingleton<GameState>();
|
|
|
|
|
int chips = state.Chips;
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
if (chips < BetAmount)
|
|
|
|
|
{
|
|
|
|
|
log.Header += $" — BUSTED after {totalRounds} rounds";
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (chips >= StartingChips * 2)
|
|
|
|
|
{
|
|
|
|
|
log.Header += $" — DOUBLED after {totalRounds} rounds";
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (totalRounds >= 200)
|
|
|
|
|
{
|
|
|
|
|
log.Header += $" — MAX ROUNDS ({totalRounds})";
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
|
|
|
|
|
group.RunLogical();
|
|
|
|
|
|
|
|
|
|
int roundHits = 0;
|
2026-07-21 13:57:11 +08:00
|
|
|
int playerTurnSafety = 0;
|
|
|
|
|
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52)
|
2026-07-20 17:37:23 +08:00
|
|
|
{
|
2026-07-21 13:57:11 +08:00
|
|
|
playerTurnSafety++;
|
2026-07-20 17:37:23 +08:00
|
|
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
|
|
|
|
var decision = agent.Decide(world);
|
2026-07-21 18:09:47 +08:00
|
|
|
decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
|
2026-07-20 17:37:23 +08:00
|
|
|
|
|
|
|
|
if (decision == BlackjackDecision.Hit)
|
|
|
|
|
{
|
|
|
|
|
world.Commands.Enqueue(new HitCommand());
|
|
|
|
|
roundHits++;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
world.Commands.Enqueue(new StandCommand());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
group.RunLogical();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = world.ReadSingleton<GameState>();
|
|
|
|
|
int newChips = state.Chips;
|
|
|
|
|
int delta = newChips - chips;
|
2026-07-21 18:09:47 +08:00
|
|
|
roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
|
2026-07-20 17:37:23 +08:00
|
|
|
|
|
|
|
|
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++;
|
|
|
|
|
|
|
|
|
|
world.Commands.Enqueue(new NewRoundCommand());
|
2026-07-20 17:22:07 +08:00
|
|
|
group.RunLogical();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
log.AddSection("Round Summaries", roundSummaries);
|
|
|
|
|
log.AddSection("Decisions", decisions);
|
|
|
|
|
log.AddSection("Reactivity", capture.GetLogLines());
|
|
|
|
|
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
|
2026-07-20 17:22:07 +08:00
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
return log;
|
2026-07-20 17:22:07 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── File I/O ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private static void ReadAndVerify(string path)
|
|
|
|
|
{
|
|
|
|
|
var content = File.ReadAllText(path);
|
|
|
|
|
|
2026-07-20 17:37:23 +08:00
|
|
|
content.Should().Contain("--- Round Summaries ---");
|
2026-07-20 17:22:07 +08:00
|
|
|
content.Should().Contain("--- Decisions ---");
|
|
|
|
|
content.Should().Contain("--- Reactivity ---");
|
|
|
|
|
content.Should().Contain("--- Final State ---");
|
|
|
|
|
content.Should().Contain("Result:");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Agents ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private enum BlackjackDecision { Hit, Stand }
|
|
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
|
2026-07-20 17:22:07 +08:00
|
|
|
{
|
|
|
|
|
public BlackjackDecision Decide(World world)
|
|
|
|
|
{
|
|
|
|
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
|
|
|
|
return total < 17 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
|
|
|
|
}
|
|
|
|
|
public override string ToString() => "BasicStrategy";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 18:09:47 +08:00
|
|
|
private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
|
2026-07-20 17:22:07 +08:00
|
|
|
{
|
|
|
|
|
private static readonly Random _rng = new();
|
|
|
|
|
public BlackjackDecision Decide(World world)
|
|
|
|
|
{
|
|
|
|
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
|
|
|
|
if (total >= 21) return BlackjackDecision.Stand;
|
|
|
|
|
return _rng.Next(2) == 0 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
|
|
|
|
}
|
|
|
|
|
public override string ToString() => "Random";
|
|
|
|
|
}
|
2026-07-21 18:09:47 +08:00
|
|
|
}
|