diff --git a/Alligator.Compact.sln b/Alligator.Compact.sln
index a98084b..4f21b12 100644
--- a/Alligator.Compact.sln
+++ b/Alligator.Compact.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.3.32811.315
+# Visual Studio Version 18
+VisualStudioVersion = 18.4.11620.152 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Alligator.Solver", "Alligator.Solver\Alligator.Solver.csproj", "{683DE064-2731-4553-81F5-813840788C74}"
EndProject
@@ -15,6 +15,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Alligator.Benchmark", "Alli
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alligator.TicTacToe", "Alligator.TicTacToe\Alligator.TicTacToe.csproj", "{28060B42-B42E-4C1C-895C-FD0B0C656553}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alligator.ConnectFour", "Alligator.ConnectFour\Alligator.ConnectFour.csproj", "{9BB16125-E04A-4F2A-CDEA-DA954DCD6453}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -45,6 +47,10 @@ Global
{28060B42-B42E-4C1C-895C-FD0B0C656553}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28060B42-B42E-4C1C-895C-FD0B0C656553}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28060B42-B42E-4C1C-895C-FD0B0C656553}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9BB16125-E04A-4F2A-CDEA-DA954DCD6453}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9BB16125-E04A-4F2A-CDEA-DA954DCD6453}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9BB16125-E04A-4F2A-CDEA-DA954DCD6453}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9BB16125-E04A-4F2A-CDEA-DA954DCD6453}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Alligator.ConnectFour/Alligator.ConnectFour.csproj b/Alligator.ConnectFour/Alligator.ConnectFour.csproj
new file mode 100644
index 0000000..d715e66
--- /dev/null
+++ b/Alligator.ConnectFour/Alligator.ConnectFour.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/Alligator.ConnectFour/Configuration.cs b/Alligator.ConnectFour/Configuration.cs
new file mode 100644
index 0000000..1a709f5
--- /dev/null
+++ b/Alligator.ConnectFour/Configuration.cs
@@ -0,0 +1,9 @@
+using Alligator.Solver;
+
+namespace Alligator.ConnectFour
+{
+ internal class Configuration : IConfiguration
+ {
+ public int MaxDepth => 13;
+ }
+}
diff --git a/Alligator.ConnectFour/Disk.cs b/Alligator.ConnectFour/Disk.cs
new file mode 100644
index 0000000..091c9df
--- /dev/null
+++ b/Alligator.ConnectFour/Disk.cs
@@ -0,0 +1,9 @@
+namespace Alligator.ConnectFour
+{
+ public enum Disk
+ {
+ None = 0,
+ Red,
+ Yellow
+ }
+}
diff --git a/Alligator.ConnectFour/Drop.cs b/Alligator.ConnectFour/Drop.cs
new file mode 100644
index 0000000..4c94c35
--- /dev/null
+++ b/Alligator.ConnectFour/Drop.cs
@@ -0,0 +1,34 @@
+namespace Alligator.ConnectFour
+{
+ public class Drop
+ {
+ public int Column { get; }
+
+ private static readonly Drop[] instances;
+
+ static Drop()
+ {
+ instances = new Drop[Position.Columns];
+ for (int c = 0; c < Position.Columns; c++)
+ {
+ instances[c] = new Drop(c);
+ }
+ }
+
+ private Drop(int column)
+ {
+ Column = column;
+ }
+
+ public static Drop At(int column) => instances[column];
+
+ public override bool Equals(object? obj)
+ {
+ return obj is Drop other && Column == other.Column;
+ }
+
+ public override int GetHashCode() => Column;
+
+ public override string ToString() => $"Col({Column})";
+ }
+}
\ No newline at end of file
diff --git a/Alligator.ConnectFour/Position.cs b/Alligator.ConnectFour/Position.cs
new file mode 100644
index 0000000..fe6b28b
--- /dev/null
+++ b/Alligator.ConnectFour/Position.cs
@@ -0,0 +1,265 @@
+using Alligator.Solver;
+
+namespace Alligator.ConnectFour
+{
+ public class Position : IPosition
+ {
+ public const int Columns = 7;
+ public const int Rows = 6;
+ public const int WinLength = 4;
+
+ private readonly Disk[,] board; // [row, col] — row 0 is the bottom
+ private readonly int[] heights;
+
+ private Disk nextDisk;
+ private ulong identifier;
+
+ private readonly Stack<(int Column, ulong PrevIdentifier)> history;
+
+ private static readonly ulong[,,] zobristTable;
+ private static readonly ulong zobristTurn;
+
+ private static readonly int[,] cellWeights =
+ {
+ { 8, 9, 10, 12, 10, 9, 8 },
+ { 7, 8, 9, 11, 9, 8, 7 },
+ { 6, 7, 8, 10, 8, 7, 6 },
+ { 5, 6, 7, 9, 7, 6, 5 },
+ { 4, 5, 6, 8, 6, 5, 4 },
+ { 3, 4, 5, 7, 5, 4, 3 }
+ };
+
+ static Position()
+ {
+ var rng = new Random(42);
+ zobristTable = new ulong[2, Rows, Columns];
+ for (int color = 0; color < 2; color++)
+ {
+ for (int r = 0; r < Rows; r++)
+ {
+ for (int c = 0; c < Columns; c++)
+ {
+ zobristTable[color, r, c] = NextRandomULong(rng);
+ }
+ }
+ }
+ zobristTurn = NextRandomULong(rng);
+ }
+
+ public Position()
+ {
+ board = new Disk[Rows, Columns];
+ heights = new int[Columns];
+ nextDisk = Disk.Red;
+ identifier = 0UL;
+ history = new Stack<(int, ulong)>();
+ }
+
+ public ulong Identifier => identifier;
+
+ public Disk Next => nextDisk;
+
+ public int MoveCount => history.Count;
+
+ // Always from Red's perspective (absolute convention expected by solver)
+ public sbyte Value => Evaluate();
+
+ public void Take(Drop step)
+ {
+ int col = step.Column;
+ int row = heights[col];
+
+ history.Push((col, identifier));
+
+ board[row, col] = nextDisk;
+ heights[col] = row + 1;
+
+ int colorIndex = nextDisk == Disk.Red ? 0 : 1;
+ identifier ^= zobristTable[colorIndex, row, col];
+ identifier ^= zobristTurn;
+
+ nextDisk = nextDisk == Disk.Red ? Disk.Yellow : Disk.Red;
+ }
+
+ public void TakeBack()
+ {
+ var (col, prevId) = history.Pop();
+
+ int row = heights[col] - 1;
+ board[row, col] = Disk.None;
+ heights[col] = row;
+
+ identifier = prevId;
+ nextDisk = nextDisk == Disk.Red ? Disk.Yellow : Disk.Red;
+ }
+
+ public int HeightAt(int col) => heights[col];
+
+ public Disk DiskAt(int row, int col) => board[row, col];
+
+ public bool IsBoardFull => history.Count == Rows * Columns;
+
+ public bool HasWinner()
+ {
+ if (history.Count == 0)
+ {
+ return false;
+ }
+
+ var (col, _) = history.Peek();
+ int row = heights[col] - 1;
+ Disk disk = board[row, col];
+
+ return CountDirection(row, col, disk, 0, 1) + CountDirection(row, col, disk, 0, -1) >= WinLength - 1
+ || CountDirection(row, col, disk, 1, 0) + CountDirection(row, col, disk, -1, 0) >= WinLength - 1
+ || CountDirection(row, col, disk, 1, 1) + CountDirection(row, col, disk, -1, -1) >= WinLength - 1
+ || CountDirection(row, col, disk, 1, -1) + CountDirection(row, col, disk, -1, 1) >= WinLength - 1;
+ }
+
+ private int CountDirection(int row, int col, Disk disk, int dRow, int dCol)
+ {
+ int count = 0;
+ int r = row + dRow;
+ int c = col + dCol;
+
+ while (r >= 0 && r < Rows && c >= 0 && c < Columns && board[r, c] == disk)
+ {
+ count++;
+ r += dRow;
+ c += dCol;
+ }
+
+ return count;
+ }
+
+ private sbyte Evaluate()
+ {
+ int score = 0;
+
+ for (int r = 0; r < Rows; r++)
+ {
+ for (int c = 0; c < Columns; c++)
+ {
+ if (board[r, c] == Disk.Red) score += cellWeights[r, c];
+ else if (board[r, c] == Disk.Yellow) score -= cellWeights[r, c];
+ }
+ }
+
+ int redThreats = 0, yellowThreats = 0;
+ score += EvaluateAllWindows(ref redThreats, ref yellowThreats);
+
+ if (redThreats >= 2) score += 80;
+ if (yellowThreats >= 2) score -= 80;
+
+ // sbyte.MaxValue is reserved for wins
+ score = Math.Clamp(score, -126, 126);
+
+ return (sbyte)score;
+ }
+
+ private int EvaluateAllWindows(ref int redThreats, ref int yellowThreats)
+ {
+ int score = 0;
+
+ for (int r = 0; r < Rows; r++)
+ {
+ for (int c = 0; c <= Columns - WinLength; c++)
+ {
+ score += EvaluateWindow(r, c, 0, 1, ref redThreats, ref yellowThreats);
+ }
+ }
+
+ for (int c = 0; c < Columns; c++)
+ {
+ for (int r = 0; r <= Rows - WinLength; r++)
+ {
+ score += EvaluateWindow(r, c, 1, 0, ref redThreats, ref yellowThreats);
+ }
+ }
+
+ for (int r = 0; r <= Rows - WinLength; r++)
+ {
+ for (int c = 0; c <= Columns - WinLength; c++)
+ {
+ score += EvaluateWindow(r, c, 1, 1, ref redThreats, ref yellowThreats);
+ }
+ }
+
+ for (int r = WinLength - 1; r < Rows; r++)
+ {
+ for (int c = 0; c <= Columns - WinLength; c++)
+ {
+ score += EvaluateWindow(r, c, -1, 1, ref redThreats, ref yellowThreats);
+ }
+ }
+
+ return score;
+ }
+
+ private int EvaluateWindow(int startRow, int startCol, int dRow, int dCol,
+ ref int redThreats, ref int yellowThreats)
+ {
+ int red = 0, yellow = 0;
+ int minDistance = int.MaxValue;
+
+ for (int i = 0; i < WinLength; i++)
+ {
+ int r = startRow + i * dRow;
+ int c = startCol + i * dCol;
+ Disk d = board[r, c];
+ if (d == Disk.Red) red++;
+ else if (d == Disk.Yellow) yellow++;
+ else
+ {
+ int distance = r - heights[c];
+ if (distance < minDistance) minDistance = distance;
+ }
+ }
+
+ if (red > 0 && yellow > 0) return 0;
+
+ if (red == 3)
+ {
+ if (minDistance == 0) redThreats++;
+ return ScoreThreeInRow(minDistance);
+ }
+ if (yellow == 3)
+ {
+ if (minDistance == 0) yellowThreats++;
+ return -ScoreThreeInRow(minDistance);
+ }
+ if (red == 2) return ScoreTwoInRow(minDistance);
+ if (yellow == 2) return -ScoreTwoInRow(minDistance);
+
+ return 0;
+ }
+
+ private static int ScoreThreeInRow(int distanceToPlayable)
+ {
+ return distanceToPlayable switch
+ {
+ 0 => 50,
+ 1 => 20,
+ 2 => 8,
+ _ => 3
+ };
+ }
+
+ private static int ScoreTwoInRow(int distanceToPlayable)
+ {
+ return distanceToPlayable switch
+ {
+ 0 => 8,
+ 1 => 4,
+ _ => 2
+ };
+ }
+
+ private static ulong NextRandomULong(Random rng)
+ {
+ byte[] buffer = new byte[8];
+ rng.NextBytes(buffer);
+ return BitConverter.ToUInt64(buffer, 0);
+ }
+ }
+}
diff --git a/Alligator.ConnectFour/Program.cs b/Alligator.ConnectFour/Program.cs
new file mode 100644
index 0000000..28cc028
--- /dev/null
+++ b/Alligator.ConnectFour/Program.cs
@@ -0,0 +1,169 @@
+using Alligator.Solver;
+
+namespace Alligator.ConnectFour
+{
+ class Program
+ {
+ static void Main(string[] args)
+ {
+ Console.ForegroundColor = ConsoleColor.White;
+ Console.WriteLine("Hello Connect Four!");
+ Console.WriteLine();
+
+ IRules rules = new Rules();
+ IConfiguration solverConfiguration = new Configuration();
+
+ SolverProvider solverFactory = new SolverProvider(rules, solverConfiguration, SolverLog);
+ ISolver solver = solverFactory.Create();
+
+ Position position = new Position();
+ IList history = new List();
+ bool aiStep = true;
+
+ while (rules.LegalStepsAt(position).Any())
+ {
+ PrintPosition(position);
+ Drop next;
+
+ if (aiStep)
+ {
+ while (true)
+ {
+ try
+ {
+ next = AiStep(history, solver);
+ position.Take(next);
+ break;
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e.Message);
+ }
+ }
+ }
+ else
+ {
+ while (true)
+ {
+ try
+ {
+ next = HumanStep(position);
+ position.Take(next);
+ break;
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e.Message);
+ }
+ }
+ }
+ history.Add(next);
+ aiStep = !aiStep;
+ }
+
+ PrintPosition(position);
+
+ if (!rules.IsGoal(position))
+ {
+ Console.WriteLine("Game over, DRAW!");
+ }
+ else
+ {
+ Console.WriteLine(string.Format("Game over, {0} WON!", aiStep ? "human" : "ai"));
+ }
+
+ Console.ReadKey();
+ }
+
+ private static Drop HumanStep(Position position)
+ {
+ Console.Write("Your turn! Enter column (0-6): ");
+ while (true)
+ {
+ try
+ {
+ string input = Console.ReadLine() ?? string.Empty;
+ int col = int.Parse(input.Trim());
+
+ if (col < 0 || col >= Position.Columns)
+ {
+ throw new ArgumentOutOfRangeException($"Column must be between 0 and {Position.Columns - 1}.");
+ }
+ if (position.HeightAt(col) >= Position.Rows)
+ {
+ throw new InvalidOperationException($"Column {col} is full.");
+ }
+
+ return Drop.At(col);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e.Message);
+ Console.Write("Try again (0-6): ");
+ }
+ }
+ }
+
+ private static Drop AiStep(IList history, ISolver solver)
+ {
+ Console.ForegroundColor = ConsoleColor.Gray;
+ Console.WriteLine("AI is thinking...");
+
+ var next = solver.OptimizeNextStep(history);
+
+ Console.WriteLine(string.Format("AI drops into column {0}", next.Column));
+ Console.ForegroundColor = ConsoleColor.White;
+
+ return next;
+ }
+
+ private static void PrintPosition(Position position)
+ {
+ Console.WriteLine();
+
+ // Column headers
+ Console.Write(" ");
+ for (int c = 0; c < Position.Columns; c++)
+ {
+ Console.Write(string.Format(" {0}", c));
+ }
+ Console.WriteLine();
+
+ // Board — row 5 (top) to row 0 (bottom)
+ for (int r = Position.Rows - 1; r >= 0; r--)
+ {
+ Console.Write(" ");
+ for (int c = 0; c < Position.Columns; c++)
+ {
+ Disk disk = position.DiskAt(r, c);
+ switch (disk)
+ {
+ case Disk.None:
+ Console.Write(" .");
+ break;
+ case Disk.Red:
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Write(" X");
+ Console.ForegroundColor = ConsoleColor.White;
+ break;
+ case Disk.Yellow:
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write(" O");
+ Console.ForegroundColor = ConsoleColor.White;
+ break;
+ }
+ }
+ Console.WriteLine();
+ }
+ Console.WriteLine();
+ }
+
+ private static void SolverLog(string message)
+ {
+ var prevColor = Console.ForegroundColor;
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine(string.Format("[SolverLog] {0}", message));
+ Console.ForegroundColor = prevColor;
+ }
+ }
+}
diff --git a/Alligator.ConnectFour/Rules.cs b/Alligator.ConnectFour/Rules.cs
new file mode 100644
index 0000000..8f050d6
--- /dev/null
+++ b/Alligator.ConnectFour/Rules.cs
@@ -0,0 +1,47 @@
+using Alligator.Solver;
+
+namespace Alligator.ConnectFour
+{
+ public class Rules : IRules
+ {
+ public Position InitialPosition()
+ {
+ return new Position();
+ }
+
+ public IEnumerable LegalStepsAt(Position position)
+ {
+ if (position.HasWinner() || position.IsBoardFull)
+ {
+ yield break;
+ }
+
+ int center = Position.Columns / 2;
+
+ if (position.HeightAt(center) < Position.Rows)
+ {
+ yield return Drop.At(center);
+ }
+
+ for (int offset = 1; offset <= Position.Columns / 2; offset++)
+ {
+ int left = center - offset;
+ int right = center + offset;
+
+ if (left >= 0 && position.HeightAt(left) < Position.Rows)
+ {
+ yield return Drop.At(left);
+ }
+ if (right < Position.Columns && position.HeightAt(right) < Position.Rows)
+ {
+ yield return Drop.At(right);
+ }
+ }
+ }
+
+ public bool IsGoal(Position position)
+ {
+ return position.HasWinner();
+ }
+ }
+}
diff --git a/Alligator.Solver/Algorithms/AlphaBetaSolver.cs b/Alligator.Solver/Algorithms/AlphaBetaSolver.cs
index cc69bc4..73431b5 100644
--- a/Alligator.Solver/Algorithms/AlphaBetaSolver.cs
+++ b/Alligator.Solver/Algorithms/AlphaBetaSolver.cs
@@ -9,19 +9,20 @@ internal class AlphaBetaSolver : ISolver
private readonly IRules rules;
private readonly ISearchManager searchManager;
private readonly Action logger;
-
- private const int maxDepth = 7; // TODO: magic number!
+ private readonly int maxDepth;
public AlphaBetaSolver(
AlphaBetaPruning alphaBetaPruning,
IRules rules,
ISearchManager searchManager,
- Action logger)
+ Action logger,
+ int maxDepth)
{
this.alphaBetaPruning = alphaBetaPruning ?? throw new ArgumentNullException(nameof(alphaBetaPruning));
this.rules = rules ?? throw new ArgumentNullException(nameof(rules));
this.searchManager = searchManager ?? throw new ArgumentNullException(nameof(searchManager));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ this.maxDepth = maxDepth;
}
public TStep OptimizeNextStep(IList history)
diff --git a/Alligator.Solver/IConfiguration.cs b/Alligator.Solver/IConfiguration.cs
index cd520e2..7d67275 100644
--- a/Alligator.Solver/IConfiguration.cs
+++ b/Alligator.Solver/IConfiguration.cs
@@ -5,5 +5,10 @@
///
public interface IConfiguration
{
+ ///
+ /// Maximum search depth for iterative deepening (exclusive upper bound).
+ /// The solver searches at even depths: 2, 4, 6, ... up to the largest even less than this value.
+ ///
+ int MaxDepth => 7;
}
}
\ No newline at end of file
diff --git a/Alligator.Solver/SolverProvider.cs b/Alligator.Solver/SolverProvider.cs
index 27b2b24..cc69bf2 100644
--- a/Alligator.Solver/SolverProvider.cs
+++ b/Alligator.Solver/SolverProvider.cs
@@ -47,7 +47,8 @@ internal ISolver Create(ICacheTables cacheTables, IHeur
new AlphaBetaPruning(rules, cacheTables, heuristicTables, searchManager),
rules,
searchManager,
- logger);
+ logger,
+ solverConfiguration.MaxDepth);
}
}
}
\ No newline at end of file
diff --git a/Alligator.Test/Alligator.Test.csproj b/Alligator.Test/Alligator.Test.csproj
index c1d895d..e1413b8 100644
--- a/Alligator.Test/Alligator.Test.csproj
+++ b/Alligator.Test/Alligator.Test.csproj
@@ -21,6 +21,7 @@
+
diff --git a/Alligator.Test/ConnectFourTests.cs b/Alligator.Test/ConnectFourTests.cs
new file mode 100644
index 0000000..a277d75
--- /dev/null
+++ b/Alligator.Test/ConnectFourTests.cs
@@ -0,0 +1,156 @@
+using Alligator.ConnectFour;
+using Alligator.Solver;
+
+namespace Alligator.Test
+{
+ [TestClass]
+ public class ConnectFourTests
+ {
+ private Rules rules = null!;
+ private SolverProvider solverFactory = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ rules = new Rules();
+ solverFactory = new SolverProvider(rules, new ConnectFourConfiguration());
+ }
+
+ [TestMethod]
+ public void InitialPosition_has_7_legal_moves()
+ {
+ var position = rules.InitialPosition();
+ var moves = rules.LegalStepsAt(position).ToList();
+
+ Assert.AreEqual(Position.Columns, moves.Count);
+ }
+
+ [TestMethod]
+ public void HasWinner_detects_vertical_win()
+ {
+ var position = new Position();
+ // Red drops in column 0 four times, Yellow drops in column 1
+ position.Take(Drop.At(0)); // Red
+ position.Take(Drop.At(1)); // Yellow
+ position.Take(Drop.At(0)); // Red
+ position.Take(Drop.At(1)); // Yellow
+ position.Take(Drop.At(0)); // Red
+ position.Take(Drop.At(1)); // Yellow
+ position.Take(Drop.At(0)); // Red — 4 in column 0
+
+ Assert.IsTrue(position.HasWinner());
+ }
+
+ [TestMethod]
+ public void HasWinner_detects_horizontal_win()
+ {
+ var position = new Position();
+ // Red: cols 0,1,2,3. Yellow: cols 0,1,2 (row above)
+ position.Take(Drop.At(0)); // Red r0c0
+ position.Take(Drop.At(0)); // Yellow r1c0
+ position.Take(Drop.At(1)); // Red r0c1
+ position.Take(Drop.At(1)); // Yellow r1c1
+ position.Take(Drop.At(2)); // Red r0c2
+ position.Take(Drop.At(2)); // Yellow r1c2
+ position.Take(Drop.At(3)); // Red r0c3 — 4 horizontal
+
+ Assert.IsTrue(position.HasWinner());
+ }
+
+ [TestMethod]
+ public void HasWinner_detects_diagonal_win()
+ {
+ var position = new Position();
+ // Build a diagonal for Red: (0,0), (1,1), (2,2), (3,3)
+ position.Take(Drop.At(0)); // Red r0c0
+ position.Take(Drop.At(1)); // Yellow r0c1
+ position.Take(Drop.At(1)); // Red r1c1
+ position.Take(Drop.At(2)); // Yellow r0c2
+ position.Take(Drop.At(2)); // Red r1c2
+ position.Take(Drop.At(3)); // Yellow r0c3
+ position.Take(Drop.At(2)); // Red r2c2
+ position.Take(Drop.At(3)); // Yellow r1c3
+ position.Take(Drop.At(3)); // Red r2c3
+ position.Take(Drop.At(4)); // Yellow r0c4
+ position.Take(Drop.At(3)); // Red r3c3 — diagonal win
+
+ Assert.IsTrue(position.HasWinner());
+ }
+
+ [TestMethod]
+ public void No_legal_moves_after_win()
+ {
+ var position = new Position();
+ position.Take(Drop.At(0));
+ position.Take(Drop.At(1));
+ position.Take(Drop.At(0));
+ position.Take(Drop.At(1));
+ position.Take(Drop.At(0));
+ position.Take(Drop.At(1));
+ position.Take(Drop.At(0)); // Red wins vertically
+
+ var moves = rules.LegalStepsAt(position).ToList();
+ Assert.AreEqual(0, moves.Count);
+ }
+
+ [TestMethod]
+ public void TakeBack_restores_position()
+ {
+ var position = new Position();
+ var idBefore = position.Identifier;
+
+ position.Take(Drop.At(3));
+ Assert.AreNotEqual(idBefore, position.Identifier);
+
+ position.TakeBack();
+ Assert.AreEqual(idBefore, position.Identifier);
+ Assert.AreEqual(0, position.HeightAt(3));
+ }
+
+ [TestMethod]
+ public void Solver_finds_winning_move_in_connect_four()
+ {
+ // Red has 3 in a row at bottom (cols 0,1,2), column 3 is open
+ // Solver should find the winning drop
+ var position = new Position();
+ position.Take(Drop.At(0)); // Red
+ position.Take(Drop.At(0)); // Yellow
+ position.Take(Drop.At(1)); // Red
+ position.Take(Drop.At(1)); // Yellow
+ position.Take(Drop.At(2)); // Red
+ position.Take(Drop.At(2)); // Yellow
+ // Red's turn — dropping in col 3 wins
+
+ var solver = solverFactory.Create();
+ var history = new List();
+ // Replay moves as history
+ history.Add(Drop.At(0)); history.Add(Drop.At(0));
+ history.Add(Drop.At(1)); history.Add(Drop.At(1));
+ history.Add(Drop.At(2)); history.Add(Drop.At(2));
+
+ var bestMove = solver.OptimizeNextStep(history);
+ Assert.AreEqual(3, bestMove.Column);
+ }
+
+ [TestMethod]
+ public void Solver_blocks_opponent_winning_move()
+ {
+ // Yellow has 3 in a row at bottom (cols 0,1,2), Red must block col 3
+ var history = new List
+ {
+ Drop.At(6), Drop.At(0), // Red col6, Yellow col0
+ Drop.At(6), Drop.At(1), // Red col6, Yellow col1
+ Drop.At(5), Drop.At(2), // Red col5, Yellow col2
+ // Red's turn — must block Yellow at col 3
+ };
+
+ var solver = solverFactory.Create();
+ var bestMove = solver.OptimizeNextStep(history);
+ Assert.AreEqual(3, bestMove.Column);
+ }
+
+ private class ConnectFourConfiguration : IConfiguration
+ {
+ }
+ }
+}
diff --git a/Alligator.Test/SolverTests.cs b/Alligator.Test/SolverTests.cs
index 6d7a35c..16b066c 100644
--- a/Alligator.Test/SolverTests.cs
+++ b/Alligator.Test/SolverTests.cs
@@ -16,11 +16,11 @@ private static int Solve(TreeNode root, int maxDepth = 7)
var rules = new TreeRules(root);
var cacheTables = new CacheTables();
var heuristicTables = new HeuristicTables();
- var searchManager = new SearchManager(maxDepth - 1);
- var alphaBeta = new AlphaBetaPruning(
- rules, cacheTables, heuristicTables, searchManager);
- var solver = new AlphaBetaSolver(
- alphaBeta, rules, searchManager, _ => { });
+var searchManager = new SearchManager(maxDepth - 1);
+var alphaBeta = new AlphaBetaPruning(
+ rules, cacheTables, heuristicTables, searchManager);
+var solver = new AlphaBetaSolver(
+ alphaBeta, rules, searchManager, _ => { }, maxDepth);
return solver.OptimizeNextStep([]);
}