using FluentAssertions; using Game.Blackjack; using OECS; using R3; using Xunit; namespace Game.Blackjack.Tests; public class PlayTests { [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}"); }); log.Header = "Blackjack: Basic Strategy (seed=42)"; var agent = new BasicStrategyAgent(); RunRound(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}"); }); log.Header = "Blackjack: Random Agent (seed=123)"; var agent = new RandomBlackjackAgent(); RunRound(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); log.Header = $"Blackjack: Weighted Pool (seed=77, agent={pool.LastPicked})"; var agent = pool.Pick(); RunRound(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 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("--- 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 = 100, CurrentBet = 0, RoundNumber = 1, Seed = seed }); world.PostChanges(); return (world, group); } private static void RunRound(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log) { world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); while (world.ReadSingleton().Phase == GamePhase.PlayerTurn) { var total = HandUtil.CalculateHand(world, new PlayerHand()); var decision = agent.Decide(world); log.Decisions.Add($"Hand={total}, Decision={decision}"); if (decision == BlackjackDecision.Hit) world.Commands.Enqueue(new HitCommand()); else world.Commands.Enqueue(new StandCommand()); group.RunLogical(); } var state = world.ReadSingleton(); log.Header += $" — Result: {state.Result}"; } // ── 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($"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("--- 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 string LastPicked { get; private set; } = ""; 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) { LastPicked = agent.ToString()!; return agent; } } LastPicked = _agents[^1].Agent.ToString()!; return _agents[^1].Agent; } } }