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.Item1.Row, iter.Item1.Col] = iter.Item2.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 = new Query().Without(); using var iter = world.Select(query); while (iter.MoveNext()) empty.Add((iter.Item1.Row, iter.Item1.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.Item1.Row, iter.Item1.Col] = iter.Item2.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; } }