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