using FluentAssertions; using Game.Blackjack; using OECS; using R3; 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 log = new PlayLog(); var (world, group) = SetupGame(seed: 42); world.ObserveComponentChanges().Subscribe(change => { var card = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); }); world.ObserveComponentChanges().Subscribe(change => { var state = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} GameState = {state}"); }); var agent = new BasicStrategyAgent(); log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})"; RunUntilDone(world, group, agent, log); log.FinalSnapshot = SnapshotWorld(world); var path = SavePlayLog("blackjack_basic_strategy.playlog", log); ReadAndVerify(path); } [Fact] public void Play_RandomAgent() { var log = new PlayLog(); var (world, group) = SetupGame(seed: 123); world.ObserveComponentChanges().Subscribe(change => { var card = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); }); world.ObserveComponentChanges().Subscribe(change => { var state = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} GameState = {state}"); }); var agent = new RandomBlackjackAgent(); log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})"; RunUntilDone(world, group, agent, log); log.FinalSnapshot = SnapshotWorld(world); var path = SavePlayLog("blackjack_random_agent.playlog", log); ReadAndVerify(path); } [Fact] public void Play_WeightedPool() { var log = new PlayLog(); var (world, group) = SetupGame(seed: 77); world.ObserveComponentChanges().Subscribe(change => { var card = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}"); }); world.ObserveComponentChanges().Subscribe(change => { var state = world.ReadComponent(change.Entity); log.Reactivity.Add($"{change.Kind} GameState = {state}"); }); var pool = new WeightedAgentPool(); pool.Add(new BasicStrategyAgent(), 7); pool.Add(new RandomBlackjackAgent(), 3); var agent = pool.Pick(); log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})"; RunUntilDone(world, group, agent, log); log.FinalSnapshot = SnapshotWorld(world); var path = SavePlayLog("blackjack_weighted_pool.playlog", log); ReadAndVerify(path); } // ── Play Log ────────────────────────────────────────────────────── private sealed class PlayLog { public string Header { get; set; } = ""; public readonly List RoundSummaries = new(); public readonly List Decisions = new(); public readonly List Reactivity = new(); public string FinalSnapshot { get; set; } = ""; public string Build() { var sb = new System.Text.StringBuilder(); 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]}"); sb.AppendLine(); sb.AppendLine("--- Reactivity ---"); foreach (var entry in Reactivity) sb.AppendLine($" {entry}"); sb.AppendLine(); sb.AppendLine("--- Final State ---"); sb.AppendLine(FinalSnapshot); return sb.ToString(); } } // ── Round Runner ────────────────────────────────────────────────── private static (World, SystemGroup) SetupGame(uint seed) { var world = new World(); var group = new SystemGroup(world); group.Add(new DeckSetupSystem()); group.Add(new DealSystem()); group.Add(new PlayerBustCheckSystem()); group.Add(new DealerSystem()); world.SetSingleton(new GameState { Phase = GamePhase.Betting, Result = RoundResult.None, Chips = StartingChips, CurrentBet = 0, RoundNumber = 1, Seed = seed }); world.PostChanges(); return (world, group); } /// /// Runs rounds until the player runs out of chips (can't afford minimum bet) /// or doubles their starting chips. /// private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log) { int totalRounds = 0; int wins = 0; int losses = 0; int pushes = 0; while (true) { var state = world.ReadSingleton(); 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().Phase == GamePhase.PlayerTurn) { var total = HandUtil.CalculateHand(world, new PlayerHand()); var decision = agent.Decide(world); 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(); } // Record round result. state = world.ReadSingleton(); 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 ────────────────────────────────────────────────────── private static string SnapshotWorld(World world) { var sb = new System.Text.StringBuilder(); var state = world.ReadSingleton(); 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(world); if (playerHand != Entity.Null) { sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):"); foreach (var card in GetHandCards(world, playerHand)) sb.AppendLine($" {card}"); } var dealerHand = FindEntity(world); if (dealerHand != Entity.Null) { sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):"); foreach (var card in GetHandCards(world, dealerHand)) sb.AppendLine($" {card}"); } return sb.ToString().TrimEnd(); } private static Entity FindEntity(World world) where T : struct { using var iter = world.Select(); while (iter.MoveNext()) if (iter.CurrentEntity != World.SingletonEntity) return iter.CurrentEntity; return Entity.Null; } private static List GetHandCards(World world, Entity hand) { var cards = new List(); foreach (var cardEntity in world.GetSources(hand)) { var card = world.ReadComponent(cardEntity); cards.Add($"{card.Rank} of {card.Suit}"); } return cards; } // ── File I/O ────────────────────────────────────────────────────── private static string SavePlayLog(string filename, PlayLog log) { var dir = Path.Combine(AppContext.BaseDirectory, "playlogs"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, filename); File.WriteAllText(path, log.Build()); return path; } 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 interface IBlackjackAgent { BlackjackDecision Decide(World world); } private sealed class BasicStrategyAgent : IBlackjackAgent { 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 : IBlackjackAgent { 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"; } private sealed class WeightedAgentPool { private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new(); private int _totalWeight; private static readonly Random _rng = new(); public void Add(IBlackjackAgent agent, int weight) { _agents.Add((agent, weight)); _totalWeight += weight; } public IBlackjackAgent Pick() { int roll = _rng.Next(_totalWeight); int cumulative = 0; foreach (var (agent, weight) in _agents) { cumulative += weight; if (roll < cumulative) return agent; } return _agents[^1].Agent; } } }