279 lines
9.4 KiB
C#
279 lines
9.4 KiB
C#
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<Mark>().Subscribe(change =>
|
|
{
|
|
var mark = world.ReadComponent<Mark>(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<Mark>().Subscribe(change =>
|
|
{
|
|
var mark = world.ReadComponent<Mark>(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<Mark>().Subscribe(change =>
|
|
{
|
|
var mark = world.ReadComponent<Mark>(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<string> Moves = new();
|
|
public readonly List<string> 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<GameState>().Status == GameStatus.Playing)
|
|
{
|
|
var state = world.ReadSingleton<GameState>();
|
|
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<GameState>();
|
|
log.Header += $" — Result: {final.Status}";
|
|
}
|
|
|
|
// ── Snapshot ──────────────────────────────────────────────────────
|
|
|
|
private static string SnapshotWorld(World world)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
var grid = new char?[3, 3];
|
|
foreach (var it in world.Select<Cell, Mark>())
|
|
grid[it.Item1.Row, it.Item1.Col] =
|
|
it.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<GameState>();
|
|
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)>();
|
|
foreach (var it in world.Select(new Query<Cell>().Without<Mark>()))
|
|
empty.Add((it.Item1.Row, it.Item1.Col));
|
|
return empty;
|
|
}
|
|
|
|
private static bool WouldWin(World world, int row, int col, Player player)
|
|
{
|
|
var grid = new Player[3, 3];
|
|
foreach (var it in world.Select<Cell, Mark>())
|
|
grid[it.Item1.Row, it.Item1.Col] = it.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;
|
|
}
|
|
}
|