diff --git a/Game.Blackjack.Tests/PlayTests.cs b/Game.Blackjack.Tests/PlayTests.cs new file mode 100644 index 0000000..d71a02b --- /dev/null +++ b/Game.Blackjack.Tests/PlayTests.cs @@ -0,0 +1,307 @@ +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; + } + } +} diff --git a/Game.TicTacToe.Tests/PlayTests.cs b/Game.TicTacToe.Tests/PlayTests.cs new file mode 100644 index 0000000..80837e2 --- /dev/null +++ b/Game.TicTacToe.Tests/PlayTests.cs @@ -0,0 +1,284 @@ +using FluentAssertions; +using Game.TicTacToe; +using OECS; +using R3; +using Xunit; + +namespace Game.TicTacToe.Tests; + +public class PlayTests +{ + [Fact] + public void Play_GreedyVsRandom() + { + var log = new PlayLog(); + + var (world, group) = SetupGame(); + var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); + var agentO = new RandomAgent(); + + // Subscribe reactivity. + world.ObserveComponentChanges().Subscribe(change => + { + var mark = world.ReadComponent(change.Entity); + log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); + }); + + log.Header = "TicTacToe: Greedy X vs Random O"; + + RunGame(world, group, agentX, agentO, log); + + log.FinalSnapshot = SnapshotWorld(world); + + var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log); + ReadAndVerify(path); + } + + [Fact] + public void Play_RandomVsRandom() + { + var log = new PlayLog(); + + var (world, group) = SetupGame(); + var agentX = new RandomAgent(); + var agentO = new RandomAgent(); + + world.ObserveComponentChanges().Subscribe(change => + { + var mark = world.ReadComponent(change.Entity); + log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); + }); + + log.Header = "TicTacToe: Random X vs Random O"; + + RunGame(world, group, agentX, agentO, log); + + log.FinalSnapshot = SnapshotWorld(world); + + var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log); + ReadAndVerify(path); + } + + [Fact] + public void Play_GreedyVsGreedy() + { + var log = new PlayLog(); + + var (world, group) = SetupGame(); + var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); + var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock); + + world.ObserveComponentChanges().Subscribe(change => + { + var mark = world.ReadComponent(change.Entity); + log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}"); + }); + + log.Header = "TicTacToe: Greedy X vs Greedy O"; + + RunGame(world, group, agentX, agentO, log); + + log.FinalSnapshot = SnapshotWorld(world); + + var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log); + ReadAndVerify(path); + } + + // ── Play Log ────────────────────────────────────────────────────── + + private sealed class PlayLog + { + public string Header { get; set; } = ""; + public readonly List Moves = 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("--- Moves ---"); + for (int i = 0; i < Moves.Count; i++) + sb.AppendLine($" {i + 1}. {Moves[i]}"); + sb.AppendLine(); + sb.AppendLine("--- Reactivity ---"); + foreach (var entry in Reactivity) + sb.AppendLine($" {entry}"); + sb.AppendLine(); + sb.AppendLine("--- Final Board ---"); + sb.AppendLine(FinalSnapshot); + return sb.ToString(); + } + } + + // ── Game Runner ─────────────────────────────────────────────────── + + private static (World, SystemGroup) SetupGame() + { + var world = new World(); + var group = new SystemGroup(world); + group.Add(new WinCheckSystem()); + + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c }); + + world.SetSingleton(new GameState + { + CurrentPlayer = Player.X, + Status = GameStatus.Playing, + MoveCount = 0 + }); + world.PostChanges(); + + return (world, group); + } + + private static void RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log) + { + while (world.ReadSingleton().Status == GameStatus.Playing) + { + var state = world.ReadSingleton(); + var agent = state.CurrentPlayer == Player.X ? agentX : agentO; + var cmd = agent.Decide(world); + + world.Commands.Enqueue(cmd); + group.RunLogical(); + + log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})"); + } + + var final = world.ReadSingleton(); + log.Header += $" — Result: {final.Status}"; + } + + // ── Snapshot ────────────────────────────────────────────────────── + + private static string SnapshotWorld(World world) + { + var sb = new System.Text.StringBuilder(); + var grid = new char?[3, 3]; + using (var iter = world.Select()) + { + while (iter.MoveNext()) + grid[iter.Current1.Row, iter.Current1.Col] = + iter.Current2.Player == Player.X ? 'X' : 'O'; + } + + for (int r = 0; r < 3; r++) + { + sb.Append(" "); + for (int c = 0; c < 3; c++) + sb.Append(grid[r, c]?.ToString() ?? "."); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + } + + // ── 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); + + // Verify the play log is well-formed. + content.Should().Contain("--- Moves ---"); + content.Should().Contain("--- Reactivity ---"); + content.Should().Contain("--- Final Board ---"); + + // The game must have finished. + content.Should().Contain("Result:"); + content.Should().NotContain("Result: Playing"); + } + + // ── Agents ──────────────────────────────────────────────────────── + + private interface IAgent + { + PlaceMarkCommand Decide(World world); + } + + private sealed class RandomAgent : IAgent + { + private static readonly Random _rng = new(); + public PlaceMarkCommand Decide(World world) + { + var empty = GetEmptyCells(world); + var (row, col) = empty[_rng.Next(empty.Count)]; + return new PlaceMarkCommand { Row = row, Col = col }; + } + } + + private sealed class GreedyAgent : IAgent + { + public enum Strategy { WinOrBlock } + private readonly Strategy _strategy; + private static readonly Random _rng = new(); + public GreedyAgent(Strategy strategy) => _strategy = strategy; + + public PlaceMarkCommand Decide(World world) + { + var state = world.ReadSingleton(); + var me = state.CurrentPlayer; + var empty = GetEmptyCells(world); + + foreach (var (r, c) in empty) + if (WouldWin(world, r, c, me)) + return new PlaceMarkCommand { Row = r, Col = c }; + + var opponent = me == Player.X ? Player.O : Player.X; + foreach (var (r, c) in empty) + if (WouldWin(world, r, c, opponent)) + return new PlaceMarkCommand { Row = r, Col = c }; + + if (empty.Any(c => c.Row == 1 && c.Col == 1)) + return new PlaceMarkCommand { Row = 1, Col = 1 }; + + var (rr, cc) = empty[_rng.Next(empty.Count)]; + return new PlaceMarkCommand { Row = rr, Col = cc }; + } + } + + private static List<(int Row, int Col)> GetEmptyCells(World world) + { + var empty = new List<(int, int)>(); + var query = world.Query().With().Without().Build(); + using var iter = world.Select(query); + while (iter.MoveNext()) + empty.Add((iter.Current1.Row, iter.Current1.Col)); + return empty; + } + + private static bool WouldWin(World world, int row, int col, Player player) + { + var grid = new Player[3, 3]; + using (var iter = world.Select()) + while (iter.MoveNext()) + grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player; + grid[row, col] = player; + + for (int r = 0; r < 3; r++) + if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player) + return true; + for (int c = 0; c < 3; c++) + if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player) + return true; + if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player) + return true; + if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player) + return true; + + return false; + } +} diff --git a/docs/testing-games.md b/docs/testing-games.md index 304cda9..86db89c 100644 --- a/docs/testing-games.md +++ b/docs/testing-games.md @@ -8,7 +8,9 @@ ## Play tests -Playtests are games executed with AI players who give unexpected play commands. +The goal of playtests is to execute a game with AI players and potentially random seeds. Then the log/snapshots are read by the LLM agent to spot issues. + +There is no failure in play tests; all tests pass but the agent should read the log for reference. AI players are not LLMs here; they are typically common game AI implementations. @@ -17,3 +19,5 @@ Create static functions that analyze the game state and return a command. Each s Agents to consider: - Random: evenly chooses every move randomly. - Greedy: uses a specific way to score actions. Always chooses the onewith highest score. There can be multiple Greedy agents wtih different scoring. + +For each playtest attempt, generate a full game log, analyze and report what you find from the log.