From 7f58e7a5ee075554f47a264278aed30504e78e7b Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 10 Sep 2026 02:49:32 +0300 Subject: [PATCH 1/2] feat: complete standard game rules and safe public API --- .../Program.cs | 68 ++- .../PerftDriver.cs | 12 +- .../ChessGameTests.cs | 22 +- .../CompleteGameRulesTests.cs | 488 ++++++++++++++++++ .../Core/MoveGeneration/AllMovesTests.cs | 8 +- .../MoveGeneration/Knight/KnightMovesTests.cs | 52 +- .../MoveGeneration/Pawn/EnpassantMoveTests.cs | 17 +- .../MoveGeneration/Pawn/PromoteMoveTests.cs | 37 +- .../MoveGeneration/Pawn/QuietMoveTests.cs | 11 +- .../Parsing/StrictFenTests.cs | 141 +++++ src/ChessRealms.ChessEngine/AlgebraicMove.cs | 35 +- src/ChessRealms.ChessEngine/ChessGame.cs | 310 +++++------ .../Core/Movements/CastlingMovement.cs | 66 +-- .../Core/Movements/LeapingMovement.cs | 18 +- .../Core/Movements/MoveDriver.cs | 35 +- .../Core/Movements/MoveGen.cs | 41 +- .../Core/Movements/PawnMovement.cs | 18 +- .../Core/Movements/SlidingMovement.cs | 16 +- .../Core/Types/Position.cs | 106 ++-- .../Core/Types/PositionValidation.cs | 61 +++ src/ChessRealms.ChessEngine/FinishReason.cs | 16 +- src/ChessRealms.ChessEngine/GameOutcome.cs | 14 + .../Parsing/AlgebraicNotation.cs | 84 +-- .../Parsing/FenStrings.cs | 197 ++++--- 24 files changed, 1268 insertions(+), 605 deletions(-) create mode 100644 src/ChessRealms.ChessEngine.Tests/CompleteGameRulesTests.cs create mode 100644 src/ChessRealms.ChessEngine.Tests/Parsing/StrictFenTests.cs create mode 100644 src/ChessRealms.ChessEngine/Core/Types/PositionValidation.cs create mode 100644 src/ChessRealms.ChessEngine/GameOutcome.cs diff --git a/src/ChessRealms.ChessEngine.Console/Program.cs b/src/ChessRealms.ChessEngine.Console/Program.cs index 750d1b3..7c5c867 100644 --- a/src/ChessRealms.ChessEngine.Console/Program.cs +++ b/src/ChessRealms.ChessEngine.Console/Program.cs @@ -1,59 +1,51 @@ -using ChessRealms.ChessEngine; +using ChessRealms.ChessEngine; using ChessRealms.ChessEngine.Common; using ChessRealms.ChessEngine.Core.Math; MoveResult lastMoveResult = MoveResult.None; ChessGame chessGame = new(); +Console.WriteLine("Moves: e2e4, a7a8q/r/b/n (promotion suffix required)."); +Console.WriteLine("Commands: moves, fen, undo, claim3 [move], claim50 [move], quit."); while (true) { - Console.Clear(); - Console.WriteLine("Last move: {0}", lastMoveResult); - Console.WriteLine("Color to move: {0}", chessGame.CurrentColor); - Console.WriteLine(); - - PrintBoard(ref chessGame); - Console.WriteLine(); - - if (chessGame.IsFinished) + Console.WriteLine("Last move: {0}; side to move: {1}", lastMoveResult, chessGame.CurrentColor); + PrintBoard(chessGame); + Console.WriteLine("State: {0}; result: {1}; winner: {2}; reason: {3}", + chessGame.State, chessGame.Outcome.Result, chessGame.Outcome.Winner, chessGame.Outcome.Reason); + Console.WriteLine("Draw claims: {0}", chessGame.AvailableDrawClaims); + Console.Write("> "); + string? input = Console.ReadLine(); + if (input is null || input == "quit") break; + if (input == "fen") { Console.WriteLine(chessGame.ToFen()); continue; } + if (input == "moves") { Console.WriteLine(string.Join(" ", chessGame.GetLegalMoves())); continue; } + if (input == "undo") { - break; + Console.WriteLine(chessGame.UndoMove() ? "Move undone." : "No move to undo."); + lastMoveResult = MoveResult.None; + continue; } - - Console.Write("Enter move: "); - - string? inputMove = Console.ReadLine(); - bool success = AlgebraicMove.TryParse(inputMove, out var move) - && (lastMoveResult = chessGame.MakeMove(in move)) != MoveResult.None; - - if (!success) + string[] command = input.Split(' '); + if (command[0] is "claim3" or "claim50") { - Console.WriteLine("invalid move"); - Console.Write("Press Enter"); - Console.ReadLine(); + DrawClaim reason = command[0] == "claim3" ? DrawClaim.ThreefoldRepetition : DrawClaim.FiftyMoveRule; + bool claimed = command.Length == 1 ? chessGame.ClaimDraw(reason) + : command.Length == 2 && AlgebraicMove.TryParse(command[1], out var intended) + && chessGame.ClaimDraw(reason, intended); + Console.WriteLine(claimed ? "Draw claimed." : "Draw claim unavailable."); continue; } + bool success = AlgebraicMove.TryParse(input, out var move) + && (lastMoveResult = chessGame.MakeMove(move)) != MoveResult.None; + if (!success) Console.WriteLine("Invalid move or game already finished."); } - -if (lastMoveResult.HasFlag(MoveResult.Checkmate)) -{ - Console.WriteLine("{0} win!!!", chessGame.CurrentColor); -} -else -{ - Console.WriteLine("Draw."); -} - -Console.WriteLine("Press any key to exit..."); -Console.ReadKey(); - -static void PrintBoard(ref ChessGame chessGame) +static void PrintBoard(ChessGame chessGame) { Span pieceSpan = stackalloc ChessPiece[64]; chessGame.GetBoardToSpan(pieceSpan); Console.WriteLine(" a b c d e f g h"); - + for (int r = 7; r >= 0; --r) { Console.Write(" {0} ", r + 1); @@ -62,7 +54,7 @@ static void PrintBoard(ref ChessGame chessGame) { int square = SquareOps.FromFileRank(f, r); - if (pieceSpan[square].IsEmpty()) + if (pieceSpan[square].IsEmpty()) { Console.Write('.'); } diff --git a/src/ChessRealms.ChessEngine.Perft/PerftDriver.cs b/src/ChessRealms.ChessEngine.Perft/PerftDriver.cs index fb2173a..bada5a8 100644 --- a/src/ChessRealms.ChessEngine.Perft/PerftDriver.cs +++ b/src/ChessRealms.ChessEngine.Perft/PerftDriver.cs @@ -27,13 +27,13 @@ public override readonly string ToString() } } - public static unsafe PerftResult Test(Position pos, int depth, bool upper = true) + public static PerftResult Test(Position pos, int depth, bool upper = true) { Position tmpPos = new(); - int* moves = stackalloc int[218]; + Span moves = stackalloc int[MoveGen.MaxMoves]; - int written = MoveGen.WriteMovesToPtrUnsafe( - &pos, pos.color, moves); + int written = MoveGen.WriteMoves( + ref pos, pos.color, moves); if (depth == 1) { @@ -41,7 +41,7 @@ public static unsafe PerftResult Test(Position pos, int depth, bool upper = true for (int i = 0; i < written; ++i) { - pos.CopyTo(&tmpPos); + tmpPos = pos; MoveDriver.MakeMove(ref tmpPos, moves[i]); @@ -74,7 +74,7 @@ public static unsafe PerftResult Test(Position pos, int depth, bool upper = true for (int i = 0; i < written; ++i) { - pos.CopyTo(&tmpPos); + tmpPos = pos; MoveDriver.MakeMove(ref tmpPos, moves[i]); diff --git a/src/ChessRealms.ChessEngine.Tests/ChessGameTests.cs b/src/ChessRealms.ChessEngine.Tests/ChessGameTests.cs index 2aa8c29..a320ed2 100644 --- a/src/ChessRealms.ChessEngine.Tests/ChessGameTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/ChessGameTests.cs @@ -18,12 +18,12 @@ private static void AssertPiece(ChessPiece[] board, string square, PieceColor co public void NewGame_HasCompleteInitialBoardAndWhiteToMove() { ChessGame game = new(); - var board = Board(game); + var board = Board(game!); PieceValue[] backRank = [PieceValue.Rook, PieceValue.Knight, PieceValue.Bishop, PieceValue.Queen, PieceValue.King, PieceValue.Bishop, PieceValue.Knight, PieceValue.Rook]; Assert.Multiple(() => { - Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.White)); + Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.White)); Assert.That(game.EnemyColor, Is.EqualTo(PieceColor.Black)); Assert.That(game.IsFinished, Is.False); Assert.That(game.HasMoves(), Is.True); @@ -42,10 +42,10 @@ public void NewGame_HasCompleteInitialBoardAndWhiteToMove() public void CreateFromFen_PreservesBoardAndBlackToMove() { Assert.That(ChessGame.TryCreateFromFen("4k3/8/8/8/8/8/4P3/4K3 b - - 0 1", out var game), Is.True); - var board = Board(game); + var board = Board(game!); Assert.Multiple(() => { - Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.Black)); + Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); Assert.That(board.Count(piece => !piece.IsEmpty()), Is.EqualTo(3)); AssertPiece(board, "e8", PieceColor.Black, PieceValue.King); AssertPiece(board, "e1", PieceColor.White, PieceValue.King); @@ -58,7 +58,7 @@ public void OrdinaryMoves_UpdateBoardAndAlternateSides() { ChessGame game = new(); Assert.That(game.MakeMove(AlgebraicMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); - Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.Black)); + Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); var expected = Board(new ChessGame()); expected[AlgebraicNotation.ParseSquare("e2")] = ChessPiece.Empty; expected[AlgebraicNotation.ParseSquare("e4")] = new(PieceColor.White, PieceValue.Pawn); @@ -67,7 +67,7 @@ public void OrdinaryMoves_UpdateBoardAndAlternateSides() expected[AlgebraicNotation.ParseSquare("e7")] = ChessPiece.Empty; expected[AlgebraicNotation.ParseSquare("e5")] = new(PieceColor.Black, PieceValue.Pawn); Assert.That(Board(game), Is.EqualTo(expected)); - Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.White)); + Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.White)); } [Test] @@ -76,12 +76,12 @@ public void Capture_RemovesEnemyAndMovesAttacker() ChessGame game = new(); Assert.That(game.MakeMove(AlgebraicMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); Assert.That(game.MakeMove(AlgebraicMove.Parse("d7d5")), Is.EqualTo(MoveResult.Move)); - var expected = Board(game); + var expected = Board(game!); expected[AlgebraicNotation.ParseSquare("e4")] = ChessPiece.Empty; expected[AlgebraicNotation.ParseSquare("d5")] = new(PieceColor.White, PieceValue.Pawn); Assert.That(game.MakeMove(AlgebraicMove.Parse("e4d5")), Is.EqualTo(MoveResult.Move | MoveResult.Capture)); Assert.That(Board(game), Is.EqualTo(expected)); - Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.Black)); + Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); } [TestCase(FenStrings.StartPosition, "e2e5")] @@ -91,14 +91,14 @@ public void Capture_RemovesEnemyAndMovesAttacker() public void IllegalMove_PreservesBoardTurnAndFinishedState(string fen, string move) { Assert.That(ChessGame.TryCreateFromFen(fen, out var game), Is.True); - var before = Board(game); - var color = game.CurrentColor; + var before = Board(game!); + var color = game!.CurrentColor; var finished = game.IsFinished; Assert.That(game.MakeMove(AlgebraicMove.Parse(move)), Is.EqualTo(MoveResult.None)); Assert.Multiple(() => { Assert.That(Board(game), Is.EqualTo(before)); - Assert.That(game.CurrentColor, Is.EqualTo(color)); + Assert.That(game!.CurrentColor, Is.EqualTo(color)); Assert.That(game.IsFinished, Is.EqualTo(finished)); }); } diff --git a/src/ChessRealms.ChessEngine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.ChessEngine.Tests/CompleteGameRulesTests.cs new file mode 100644 index 0000000..d1ca4fa --- /dev/null +++ b/src/ChessRealms.ChessEngine.Tests/CompleteGameRulesTests.cs @@ -0,0 +1,488 @@ +using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Types; +using ChessRealms.ChessEngine.Core.Movements; +using ChessRealms.ChessEngine.Parsing; +using System.Numerics; + +namespace ChessRealms.ChessEngine.Tests; + +internal class CompleteGameRulesTests +{ + private static ChessGame Game(string fen) + { + if (!ChessGame.TryCreateFromFen(fen, out var game)) Assert.Fail("Invalid fixture: " + fen); + return game!; + } + + private static void Play(ChessGame game, string moves) + { + foreach (string move in moves.Split(' ')) + Assert.That(game.MakeMove(AlgebraicMove.Parse(move)), Is.Not.EqualTo(MoveResult.None), move); + } + + private static string State(ChessGame game) => string.Join("|", game.ToFen(), game.Outcome, game.State, + game.RepetitionCount, game.AvailableDrawClaims, string.Join(";", game.History), + string.Join(",", game.GetLegalMoves())); + + private static ChessPiece At(ChessGame game, string square) + { + var board = new ChessPiece[64]; + game.GetBoardToSpan(board); + return board[AlgebraicNotation.ParseSquare(square)]; + } + + [TestCase("")] + [TestCase("e2e4x")] + [TestCase("e2e4qq")] + [TestCase("E2e4")] + [TestCase("e2e4 ")] + [TestCase(" e2e4")] + [TestCase("i2e4")] + [TestCase("e0e4")] + [TestCase("e2e2")] + [TestCase("a7a8Q")] + [TestCase("a7a8p")] + [TestCase("a7a8k")] + public void CoordinateInput_IsStrict(string input) + { + Assert.That(AlgebraicMove.TryParse(input, out var move), Is.False); + Assert.That(move, Is.EqualTo(AlgebraicMove.Empty)); + Assert.Throws(() => AlgebraicMove.Parse(input)); + } + + [TestCase("e2e4", PieceValue.None)] + [TestCase("a7a8q", PieceValue.Queen)] + [TestCase("a7a8r", PieceValue.Rook)] + [TestCase("a7a8b", PieceValue.Bishop)] + [TestCase("a7a8n", PieceValue.Knight)] + public void CoordinateInput_RoundTrips(string input, PieceValue promotion) + { + Assert.That(AlgebraicMove.TryParse(input, out var move), Is.True); + Assert.That(move.Promotion, Is.EqualTo(promotion)); + Assert.That(move.ToString(), Is.EqualTo(input)); + Assert.That(move, Is.EqualTo(AlgebraicMove.Parse(input))); + } + + [Test] + public void InitialPosition_ClocksRightsAndPositionCopy() + { + ChessGame game = new(); + Assert.That(game.ToFen(), Is.EqualTo(FenStrings.StartPosition)); + Assert.That(FenStrings.Format(Position.CreateDefault()), Is.EqualTo(FenStrings.StartPosition)); + Assert.That(game.GetLegalMoves(), Has.Count.EqualTo(20)); + var snapshot = game.Position; + snapshot.PopPieceAt(Squares.a1, Pieces.Rook, Colors.White); + Assert.That(game.ToFen(), Is.EqualTo(FenStrings.StartPosition)); + } + + [Test] + public void RejectedMovesAndSnapshots_CannotMutateOwnedHistory() + { + ChessGame game = new(); + var oldHistory = game.History; + var oldMoves = game.GetLegalMoves(); + Play(game, "e2e4"); + Assert.That(oldHistory, Is.Empty); + Assert.That(oldMoves, Has.Count.EqualTo(20)); + string before = State(game); + foreach (var move in new[] { default(AlgebraicMove), new AlgebraicMove(-1, 64), + new AlgebraicMove(Squares.e7, Squares.e5, (PieceValue)99), + AlgebraicMove.Parse("e7e5q"), AlgebraicMove.Parse("e2e4") }) + Assert.That(game.MakeMove(move), Is.EqualTo(MoveResult.None)); + var history = (IList)game.History; + Assert.Throws(() => history.Clear()); + var legal = (IList)game.GetLegalMoves(); + Assert.Throws(() => legal[0] = AlgebraicMove.Empty); + Assert.That(State(game), Is.EqualTo(before)); + } + + [Test] + public void Promotions_ApplyExactlyOneVariant_AndUndo( + [Values(false, true)] bool black, [Values(false, true)] bool capture, + [Values("q", "r", "b", "n")] string suffix) + { + string fen = black + ? capture ? "7k/8/8/8/8/8/p7/1R5K b - - 17 9" : "7k/8/8/8/8/8/p7/7K b - - 17 9" + : capture ? "1r5k/P7/8/8/8/8/8/7K w - - 17 9" : "7k/P7/8/8/8/8/8/7K w - - 17 9"; + var game = Game(fen); + string src = black ? "a2" : "a7"; + string trg = (capture ? "b" : "a") + (black ? "1" : "8"); + string before = State(game); + var moves = game.GetLegalMoves().Where(m => (int)m.Src == AlgebraicNotation.ParseSquare(src) + && (int)m.Trg == AlgebraicNotation.ParseSquare(trg)).ToArray(); + Assert.That(moves, Has.Length.EqualTo(4)); + Assert.That(moves.Select(m => m.Promotion), Is.EquivalentTo( + new[] { PieceValue.Queen, PieceValue.Rook, PieceValue.Bishop, PieceValue.Knight })); + Assert.That(game.MakeMove(AlgebraicMove.Parse(src + trg)), Is.EqualTo(MoveResult.None)); + Assert.That(State(game), Is.EqualTo(before)); + var selected = AlgebraicMove.Parse(src + trg + suffix); + var result = game.MakeMove(selected); + Assert.That(result.HasFlag(MoveResult.Move), Is.True); + Assert.That(result.HasFlag(MoveResult.Capture), Is.EqualTo(capture)); + Assert.That(At(game, src), Is.EqualTo(ChessPiece.Empty)); + Assert.That(At(game, trg), Is.EqualTo(new ChessPiece(black ? PieceColor.Black : PieceColor.White, selected.Promotion))); + Assert.That(game.History, Has.Count.EqualTo(1)); + Assert.That(game.HalfmoveClock.IsZero, Is.True); + Assert.That(game.FullmoveNumber, Is.EqualTo(new BigInteger(black ? 10 : 9))); + AssertInvariants(game.Position); + Assert.That(game.UndoMove(), Is.True); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase("7k/6Q1/6K1/8/8/8/8/8 b - - 150 1", FinishReason.Checkmate, PieceColor.White)] + [TestCase("8/8/8/8/8/6k1/6q1/7K w - - 150 1", FinishReason.Checkmate, PieceColor.Black)] + [TestCase("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", FinishReason.Stalemate, PieceColor.None)] + [TestCase("8/8/8/8/8/6k1/5q2/7K w - - 0 1", FinishReason.Stalemate, PieceColor.None)] + public void LoadedTerminalPosition_IsClassifiedAndRejectsMoves(string fen, FinishReason reason, PieceColor winner) + { + var game = Game(fen); + Assert.That(game.IsFinished, Is.True); + Assert.That(game.Outcome.Reason, Is.EqualTo(reason)); + Assert.That(game.Outcome.Winner, Is.EqualTo(winner)); + Assert.That(game.GetLegalMoves(), Is.Empty); + string before = State(game); + Assert.That(game.MakeMove(AlgebraicMove.Parse("h8h7")), Is.EqualTo(MoveResult.None)); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule), Is.False); + Assert.That(game.UndoMove(), Is.False); + Assert.That(State(game), Is.EqualTo(before)); + } + + [Test] + public void Mate_SwitchesSide_RejectsFurtherMoves_AndUndoes() + { + ChessGame game = new(); + Play(game, "f2f3 e7e5 g2g4"); + string beforeMate = State(game); + var result = game.MakeMove(AlgebraicMove.Parse("d8h4")); + Assert.That(result.HasFlag(MoveResult.Checkmate), Is.True); + Assert.That(game.CurrentColor, Is.EqualTo(PieceColor.White)); + Assert.That(game.Outcome, Is.EqualTo(new GameOutcome(GameResult.BlackWin, PieceColor.Black, FinishReason.Checkmate))); + string afterMate = State(game); + Assert.That(game.MakeMove(AlgebraicMove.Parse("a7a6")), Is.EqualTo(MoveResult.None)); + Assert.That(game.MakeMove(AlgebraicMove.Parse("a2a3")), Is.EqualTo(MoveResult.None)); + Assert.That(State(game), Is.EqualTo(afterMate)); + Assert.That(game.UndoMove(), Is.True); + Assert.That(State(game), Is.EqualTo(beforeMate)); + } + + [TestCase("w", "e1g1", "g1", "f1", "kq")] + [TestCase("w", "e1c1", "c1", "d1", "kq")] + [TestCase("b", "e8g8", "g8", "f8", "KQ")] + [TestCase("b", "e8c8", "c8", "d8", "KQ")] + public void Castling_AllFourMoves(string side, string move, string king, string rook, string rights) + { + var game = Game("r3k2r/8/8/8/8/8/8/R3K2R " + side + " KQkq - 7 3"); + string before = State(game); + Play(game, move); + var color = side == "w" ? PieceColor.White : PieceColor.Black; + Assert.That(At(game, king), Is.EqualTo(new ChessPiece(color, PieceValue.King))); + Assert.That(At(game, rook), Is.EqualTo(new ChessPiece(color, PieceValue.Rook))); + Assert.That(game.ToFen().Split(' ')[2], Is.EqualTo(rights)); + Assert.That(game.HalfmoveClock, Is.EqualTo(new BigInteger(8))); + Assert.That(game.FullmoveNumber, Is.EqualTo(new BigInteger(side == "b" ? 4 : 3))); + Assert.That(game.UndoMove(), Is.True); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase("w", "a1a2", "Kkq")] + [TestCase("w", "h1h2", "Qkq")] + [TestCase("w", "e1e2", "kq")] + [TestCase("b", "a8a7", "KQk")] + [TestCase("b", "h8h7", "KQq")] + [TestCase("b", "e8e7", "KQ")] + [TestCase("w", "a1a8", "Kk")] + [TestCase("w", "h1h8", "Qq")] + [TestCase("b", "a8a1", "Kk")] + [TestCase("b", "h8h1", "Qq")] + public void MovingKingRookOrCapturingRook_LosesRights(string side, string move, string rights) + { + var game = Game("r3k2r/8/8/8/8/8/8/R3K2R " + side + " KQkq - 0 1"); + string before = State(game); + Play(game, move); + Assert.That(game.ToFen().Split(' ')[2], Is.EqualTo(rights)); + game.UndoMove(); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase("4kr2/8/8/8/8/8/8/R3K2R w KQ - 0 1", "e1g1")] + [TestCase("r3k2r/8/8/8/8/8/8/4KR2 b kq - 0 1", "e8g8")] + [TestCase("4k3/8/8/8/8/8/8/R2bK2R w KQ - 0 1", "e1c1")] + [TestCase("r2Bk2r/8/8/8/8/8/8/4K3 b kq - 0 1", "e8c8")] + [TestCase("4r1k1/8/8/8/8/8/8/R3K2R w KQ - 0 1", "e1g1")] + public void CastlingThroughCheckOrBlockers_IsRejected(string fen, string move) + { + var game = Game(fen); + string before = State(game); + Assert.That(game.GetLegalMoves(), Does.Not.Contain(AlgebraicMove.Parse(move))); + Assert.That(game.MakeMove(AlgebraicMove.Parse(move)), Is.EqualTo(MoveResult.None)); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "e5d6", "d5")] + [TestCase("7k/8/8/8/3Pp3/8/8/7K b - d3 0 1", "e4d3", "d4")] + public void EnPassant_CapturesAndUndoes(string fen, string move, string captured) + { + var game = Game(fen); + string before = State(game); + Assert.That(game.MakeMove(AlgebraicMove.Parse(move)).HasFlag(MoveResult.Capture), Is.True); + Assert.That(At(game, captured), Is.EqualTo(ChessPiece.Empty)); + Assert.That(game.ToFen().Split(' ')[3], Is.EqualTo("-")); + Assert.That(game.HalfmoveClock.IsZero, Is.True); + AssertInvariants(game.Position); + game.UndoMove(); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase("7k/8/8/r4pPK/8/8/8/8 w - f6 0 1", "g5f6")] + [TestCase("8/8/8/8/R4Ppk/8/8/7K b - f3 0 1", "g4f3")] + [TestCase("k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1", "e5d6")] + public void EnPassant_ExposingOwnKingIsIllegal(string fen, string move) + { + var game = Game(fen); + string before = State(game); + Assert.That(game.GetLegalMoves(), Does.Not.Contain(AlgebraicMove.Parse(move))); + Assert.That(game.MakeMove(AlgebraicMove.Parse(move)), Is.EqualTo(MoveResult.None)); + Assert.That(State(game), Is.EqualTo(before)); + } + + [Test] + public void DoublePush_AlwaysRecordsTarget_AndEnPassantExpires() + { + ChessGame game = new(); + Play(game, "e2e4"); + Assert.That(game.ToFen(), Does.EndWith("b KQkq e3 0 1")); + Play(game, "h7h5"); + Assert.That(game.ToFen(), Does.EndWith("w KQkq h6 0 2")); + Play(game, "e4e5 d7d5"); + Assert.That(game.GetLegalMoves(), Does.Contain(AlgebraicMove.Parse("e5d6"))); + Play(game, "g1f3 g8f6"); + Assert.That(game.GetLegalMoves(), Does.Not.Contain(AlgebraicMove.Parse("e5d6"))); + } + + [Test] + public void Clocks_ClaimsAndAutomaticDraw_AreDistinct() + { + var game = Game("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + string before = State(game); + Assert.That(game.GetAvailableDrawClaims(AlgebraicMove.Parse("a1a2")), Is.EqualTo(DrawClaim.FiftyMoveRule)); + Assert.That(State(game), Is.EqualTo(before)); + Play(game, "a1a2"); + Assert.That(game.HalfmoveClock, Is.EqualTo(new BigInteger(100))); + Assert.That(game.FullmoveNumber, Is.EqualTo(new BigInteger(50))); + Assert.That(game.IsFinished, Is.False); + Assert.That(game.AvailableDrawClaims, Is.EqualTo(DrawClaim.FiftyMoveRule)); + Play(game, "e8e7"); + Assert.That(game.HalfmoveClock, Is.EqualTo(new BigInteger(101))); + Assert.That(game.FullmoveNumber, Is.EqualTo(new BigInteger(51))); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule), Is.True); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FiftyMoveRule)); + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.IsFinished, Is.False); + var automatic = Game("4k3/8/8/8/8/8/8/R3K3 w - - 149 80"); + Play(automatic, "a1a2"); + Assert.That(automatic.Outcome.Reason, Is.EqualTo(FinishReason.SeventyFiveMoveRule)); + Assert.That(automatic.AvailableDrawClaims, Is.EqualTo(DrawClaim.None)); + Assert.That(automatic.UndoMove(), Is.True); + Assert.That(automatic.HalfmoveClock, Is.EqualTo(new BigInteger(149))); + Assert.That(automatic.IsFinished, Is.False); + } + + [TestCase("7k/5Q2/6K1/8/8/8/8/8 w - - 149 80", "f7g7", PieceColor.White)] + [TestCase("8/8/8/8/8/6k1/5q2/7K b - - 149 80", "f2g2", PieceColor.Black)] + public void MatingMove_HasPriorityOver75MoveDraw(string fen, string move, PieceColor winner) + { + var game = Game(fen); + Play(game, move); + Assert.That(game.HalfmoveClock, Is.EqualTo(new BigInteger(150))); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.Checkmate)); + Assert.That(game.Outcome.Winner, Is.EqualTo(winner)); + } + + [Test] + public void IntendedClaim_DoesNotPlayMove_AndInvalidClaimsDoNotMutate() + { + var game = Game("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + string before = State(game); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule), Is.False); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, AlgebraicMove.Parse("a1b2")), Is.False); + Assert.That(game.ClaimDraw((DrawClaim)3, AlgebraicMove.Parse("a1a2")), Is.False); + Assert.That(State(game), Is.EqualTo(before)); + string fen = game.ToFen(); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, AlgebraicMove.Parse("a1a2")), Is.True); + Assert.That(game.ToFen(), Is.EqualTo(fen)); + Assert.That(game.History, Is.Empty); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FiftyMoveRule)); + } + + [Test] + public void Repetition_ClaimsAtThree_AutomaticAtFive_CloneAndUndoPreserveCounts() + { + ChessGame game = new(); + const string cycle = "g1f3 g8f6 f3g1 f6g8"; + Play(game, cycle); + Play(game, "g1f3 g8f6 f3g1"); + Assert.That(game.GetAvailableDrawClaims(AlgebraicMove.Parse("f6g8")), Is.EqualTo(DrawClaim.ThreefoldRepetition)); + var claiming = game.Clone(); + string claimFen = claiming.ToFen(); + Assert.That(claiming.ClaimDraw(DrawClaim.ThreefoldRepetition, AlgebraicMove.Parse("f6g8")), Is.True); + Assert.That(claiming.ToFen(), Is.EqualTo(claimFen)); + Assert.That(game.IsFinished, Is.False); + Play(game, "f6g8"); + Assert.That(game.RepetitionCount, Is.EqualTo(3)); + Assert.That(game.IsFinished, Is.False); + Assert.That(game.AvailableDrawClaims, Is.EqualTo(DrawClaim.ThreefoldRepetition)); + var claimed = game.Clone(); + Assert.That(claimed.ClaimDraw(DrawClaim.ThreefoldRepetition), Is.True); + Assert.That(claimed.Outcome.Reason, Is.EqualTo(FinishReason.ThreefoldRepetition)); + Assert.That(game.IsFinished, Is.False); + Play(game, cycle); + Assert.That(game.RepetitionCount, Is.EqualTo(4)); + Play(game, "g1f3 g8f6 f3g1"); + string before = State(game); + var copy = game.Clone(); + Play(game, "f6g8"); + Assert.That(game.RepetitionCount, Is.EqualTo(5)); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + Assert.That(State(copy), Is.EqualTo(before)); + Assert.That(game.UndoMove(), Is.True); + Assert.That(State(game), Is.EqualTo(before)); + Assert.That(State(game), Is.EqualTo(State(copy))); + Play(game, "f6g8"); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + var loaded = Game(game.ToFen()); + Assert.That(loaded.RepetitionCount, Is.EqualTo(1)); + Assert.That(loaded.History, Is.Empty); + Assert.That(loaded.IsFinished, Is.False); + } + + [TestCase("7k/8/8/3p4/8/8/8/7K w - d6 0 1", "h1g1 h8g8 g1h1 g8h8", 2)] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "h1g1 h8g8 g1h1 g8h8", 1)] + [TestCase("k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1", "e1d1 a8b8 d1e1 b8a8", 2)] + [TestCase("7k/8/8/8/3Pp3/8/8/7K b - d3 0 1", "h8g8 h1g1 g8h8 g1h1", 1)] + [TestCase("4k3/8/8/8/3Pp3/8/8/K3R3 b - d3 0 1", "e8d8 a1b1 d8e8 b1a1", 2)] + public void Repetition_UsesOnlyLegallyAvailableEnPassant(string fen, string cycle, int count) + { + var game = Game(fen); + Play(game, cycle); + Assert.That(game.RepetitionCount, Is.EqualTo(count)); + } + + [Test] + public void Repetition_DistinguishesLostCastlingRights() + { + var game = Game("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"); + const string cycle = "h1h2 h8h7 h2h1 h7h8"; + Play(game, cycle); + Assert.That(game.RepetitionCount, Is.EqualTo(1)); + Play(game, cycle); + Assert.That(game.RepetitionCount, Is.EqualTo(2)); + } + + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1", true)] + [TestCase("7k/8/8/8/8/8/8/2B4K w - - 0 1", true)] + [TestCase("7k/8/8/8/8/8/8/2N4K w - - 0 1", true)] + [TestCase("2b4k/8/8/8/8/8/8/7K b - - 0 1", true)] + [TestCase("2n4k/8/8/8/8/8/8/7K b - - 0 1", true)] + [TestCase("5b1k/8/8/8/8/8/8/2B4K w - - 0 1", true)] + [TestCase("5b1k/8/8/8/8/4B3/8/2B4K w - - 0 1", true)] + [TestCase("7k/8/8/8/8/8/8/1NN4K w - - 0 1", false)] + [TestCase("2n4k/8/8/8/8/8/8/1N5K w - - 0 1", false)] + [TestCase("2b4k/8/8/8/8/8/8/2B4K w - - 0 1", false)] + [TestCase("7k/8/8/8/8/8/8/1NB4K w - - 0 1", false)] + public void DeadPositions_AreConservative(string fen, bool dead) + { + var game = Game(fen); + Assert.That(game.Outcome.Reason, Is.EqualTo(dead ? FinishReason.DeadPosition : FinishReason.None)); + } + + [Test] + public void CaptureIntoDeadPosition_AndUndo() + { + var game = Game("7k/8/8/8/8/4n3/3B4/7K w - - 12 7"); + string before = State(game); + Play(game, "d2e3"); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.DeadPosition)); + game.UndoMove(); + Assert.That(State(game), Is.EqualTo(before)); + } + + [TestCase(17)] + [TestCase(12345)] + [TestCase(987654)] + public void DeterministicPlayouts_AllLegalMovesApplyToClones_AndUndoExactly(int seed) + { + var random = new Random(seed); + ChessGame game = new(); + var states = new List { State(game) }; + for (int ply = 0; ply < 160 && !game.IsFinished; ply++) + { + string before = State(game); + var moves = game.GetLegalMoves(); + Assert.That(moves.Distinct().Count(), Is.EqualTo(moves.Count)); + foreach (var move in moves) + { + var copy = game.Clone(); + var positionBefore = copy.Position; + Assert.That(copy.MakeMove(move), Is.Not.EqualTo(MoveResult.None), move.ToString()); + AssertInvariants(copy.Position); + Assert.That(copy.Position.IsKingChecked((int)game.CurrentColor), Is.False); + Assert.That(FenStrings.TryParse(copy.ToFen(), out var imported), Is.True); + Assert.That(FenStrings.Format(imported), Is.EqualTo(copy.ToFen())); + Assert.That(copy.UndoMove(), Is.True); + Assert.That(copy.Position, Is.EqualTo(positionBefore)); + Assert.That(State(copy), Is.EqualTo(before)); + } + Assert.That(State(game), Is.EqualTo(before)); + Assert.That(game.MakeMove(moves[random.Next(moves.Count)]), Is.Not.EqualTo(MoveResult.None)); + AssertInvariants(game.Position); + states.Add(State(game)); + } + for (int i = states.Count - 2; i >= 0; i--) + { + Assert.That(game.UndoMove(), Is.True); + Assert.That(State(game), Is.EqualTo(states[i])); + } + Assert.That(game.UndoMove(), Is.False); + } + + [TestCase("2147483647")] + [TestCase("9223372036854775807")] + [TestCase("999999999999999999999999999999999999")] + public void CountersBeyondMachineIntegerRange_AllMovesApplyAndRoundTrip(string fullmove) + { + var game = Game("4k3/8/8/8/8/8/8/R3K3 b - - 17 " + fullmove); + var before = game.Position; + var expected = BigInteger.Parse(fullmove) + 1; + foreach (var move in game.GetLegalMoves()) + { + var copy = game.Clone(); + Assert.That(copy.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); + Assert.That(copy.FullmoveNumber, Is.EqualTo(expected)); + Assert.That(Game(copy.ToFen()).Position, Is.EqualTo(copy.Position)); + Assert.That(copy.UndoMove(), Is.True); + Assert.That(copy.Position, Is.EqualTo(before)); + } + Assert.That(game.Position, Is.EqualTo(before)); + } + + private static void AssertInvariants(Position p) + { + ulong union = 0; + for (int color = 0; color < 2; color++) + { + ulong occupied = 0; + for (int piece = 0; piece < 6; piece++) + { + ulong bb = p.pieceBBs[color * 6 + piece]; + Assert.That(bb & union, Is.Zero, "Piece bitboards must be disjoint"); + union |= bb; + occupied |= bb; + } + Assert.That(p.blockers[color], Is.EqualTo(occupied)); + Assert.That(BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.King]), Is.EqualTo(1)); + Assert.That(p.pieceBBs[color * 6] & 0xff000000000000ffUL, Is.Zero); + } + Assert.That(p.blockers[2], Is.EqualTo(union)); + Assert.That(p.IsKingChecked(Colors.Mirror(p.color)), Is.False); + } +} diff --git a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/AllMovesTests.cs b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/AllMovesTests.cs index 77c3b28..8895c41 100644 --- a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/AllMovesTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/AllMovesTests.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Movements; +using ChessRealms.ChessEngine.Core.Movements; using ChessRealms.ChessEngine.Core.Types; using ChessRealms.ChessEngine.Parsing; @@ -12,9 +12,9 @@ internal unsafe class AllMovesTests public void Test1_AsBlack() { Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); - Position* positionPtr = &position; - int* moves = stackalloc int[218]; - int written = MoveGen.WriteMovesToPtrUnsafe(positionPtr, position.color, moves); + + Span moves = stackalloc int[MoveGen.MaxMoves]; + int written = MoveGen.WriteMoves(ref position, position.color, moves); Assert.That(written, Is.EqualTo(41)); } diff --git a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs index 7ba137b..0287ffc 100644 --- a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs @@ -1,4 +1,3 @@ -using ChessRealms.ChessEngine.Common; using ChessRealms.ChessEngine.Core.Attacks; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; @@ -28,7 +27,8 @@ internal unsafe class KnightMovesTests public KnightMovesTests() { - _ = FenStrings.TryParse(fen, out position); + // Deliberately synthetic geometry fixture: no kings and pawns on back ranks. + Assert.That(FenStrings.TryParseSyntax(fen, out position), Is.True); } [Test] @@ -36,24 +36,18 @@ public void Test_AsWhite() { const int color = Colors.White; const int expectedWritten = 5; - int* moves = stackalloc int[expectedWritten]; + Span moves = stackalloc int[expectedWritten]; + int written = LeapingMovement.WriteMoves( + ref position, + color, + Pieces.Knight, + KnightAttacks.AttackMasksPtr, + moves); - int written; - - fixed (Position* positionPtr = &position) - { - written = LeapingMovement.WriteMovesToPtrUnsafe( - positionPtr, - color, - Pieces.Knight, - KnightAttacks.AttackMasksPtr, - moves); - } - Assert.That(written, Is.EqualTo(expectedWritten)); - HashSet moveSet = UnsafeArrays.ToHashSet(moves, written); - + HashSet moveSet = moves[..written].ToArray().ToHashSet(); + int[] expectedMoves = [ BinaryMoveOps.EncodeMove( @@ -76,24 +70,18 @@ public void Test_AsBlack() { const int color = Colors.Black; const int expectedWritten = 5; - int* moves = stackalloc int[expectedWritten]; - - int written; - - fixed (Position* positionPtr = &position) - { - written = LeapingMovement.WriteMovesToPtrUnsafe( - positionPtr, - Colors.Black, - Pieces.Knight, - KnightAttacks.AttackMasksPtr, - moves); - } + Span moves = stackalloc int[expectedWritten]; + int written = LeapingMovement.WriteMoves( + ref position, + Colors.Black, + Pieces.Knight, + KnightAttacks.AttackMasksPtr, + moves); Assert.That(written, Is.EqualTo(expectedWritten)); - var moveSet = UnsafeArrays.ToHashSet(moves, written); - + var moveSet = moves[..written].ToArray().ToHashSet(); + int[] expectedMoves = [ BinaryMoveOps.EncodeMove( diff --git a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs index 102503f..3f331ab 100644 --- a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs @@ -1,4 +1,3 @@ -using ChessRealms.ChessEngine.Common; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Movements; @@ -36,19 +35,19 @@ public void Test_1_AsWhite() return; } - Position* positionPtr = &position; + int us = Colors.White; #region Assert by moves count - int* moves = stackalloc int[40]; - int written = PawnMovement.WriteMovesToPtrUnsafe(positionPtr, us, moves); + Span moves = stackalloc int[40]; + int written = PawnMovement.WriteMoves(ref position, us, moves); int expectedLength = 13; Assert.That(written, Is.EqualTo(expectedLength)); #endregion #region Assert by specified moves - HashSet moveSet = UnsafeArrays.ToHashSet(moves, written); + HashSet moveSet = moves[..written].ToArray().ToHashSet(); int expectedEnpassant = BinaryMoveOps.EncodeMove( Squares.b5, Pieces.Pawn, us, Squares.a6, capture: 1, enpassant: 1); @@ -81,20 +80,20 @@ public void Test_1_AsBlack() return; } - Position* positionPtr = &position; + int us = Colors.Black; #region Assert by moves count - int* moves = stackalloc int[40]; - int written = PawnMovement.WriteMovesToPtrUnsafe(positionPtr, us, moves); + Span moves = stackalloc int[40]; + int written = PawnMovement.WriteMoves(ref position, us, moves); int expectedLength = 15; Assert.That(written, Is.EqualTo(expectedLength)); #endregion #region Assert by specified moves - HashSet moveSet = UnsafeArrays.ToHashSet(moves, written); + HashSet moveSet = moves[..written].ToArray().ToHashSet(); int expectedEnpassant1 = BinaryMoveOps.EncodeMove( Squares.f4, Pieces.Pawn, us, Squares.g3, diff --git a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs index fc9fca0..7a844a0 100644 --- a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs @@ -1,4 +1,3 @@ -using ChessRealms.ChessEngine.Common; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Movements; @@ -33,23 +32,17 @@ public PromoteMoveTests() } [Test] - public void Test_Promotes_White() + public void Test_Promotes_White() { int color = Colors.White; int expectedLength = 8; - int* moves = stackalloc int[expectedLength]; + Span moves = stackalloc int[expectedLength]; + int written = PawnMovement.WriteMoves(ref position, color, moves); - int written; - - fixed (Position* positionPtr = &position) - { - written = PawnMovement.WriteMovesToPtrUnsafe(positionPtr, color, moves); - } - Assert.That(written, Is.EqualTo(expectedLength)); - HashSet moveSet = UnsafeArrays.ToHashSet(moves, written); - + HashSet moveSet = moves[..written].ToArray().ToHashSet(); + int[] expectedMoves = [ BinaryMoveOps.EncodeMove( @@ -64,7 +57,7 @@ public void Test_Promotes_White() BinaryMoveOps.EncodeMove( Squares.f7, Pieces.Pawn, color, Squares.f8, promotion: Promotions.Queen), - + BinaryMoveOps.EncodeMove( Squares.f7, Pieces.Pawn, color, Squares.e8, promotion: Promotions.Knight, capture: 1), @@ -83,22 +76,16 @@ public void Test_Promotes_White() } [Test] - public void Test_Promotes_Black() + public void Test_Promotes_Black() { int color = Colors.Black; int expectedLength = 8; - int* moves = stackalloc int[expectedLength]; - - int written; - - fixed (Position* positionPtr = &position) - { - written = PawnMovement.WriteMovesToPtrUnsafe(positionPtr, color, moves); - } - + Span moves = stackalloc int[expectedLength]; + int written = PawnMovement.WriteMoves(ref position, color, moves); + Assert.That(written, Is.EqualTo(expectedLength)); - HashSet moveSet = UnsafeArrays.ToHashSet(moves, written); + HashSet moveSet = moves[..written].ToArray().ToHashSet(); int[] expectedMoves = [ @@ -114,7 +101,7 @@ public void Test_Promotes_Black() BinaryMoveOps.EncodeMove( Squares.d2, Pieces.Pawn, color, Squares.d1, promotion: Promotions.Queen), - + BinaryMoveOps.EncodeMove( Squares.d2, Pieces.Pawn, color, Squares.c1, promotion: Promotions.Knight, capture: 1), diff --git a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs index 3465d70..60a9f11 100644 --- a/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs +++ b/src/ChessRealms.ChessEngine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs @@ -1,4 +1,3 @@ -using ChessRealms.ChessEngine.Common; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Movements; @@ -31,17 +30,17 @@ public void QuietMoves_White_StartPos() return; } - Position* positionPtr = &position; + #region Assert by move count - int* moves = stackalloc int[40]; - int written = PawnMovement.WriteMovesToPtrUnsafe(positionPtr, Colors.White, moves, 0); - + Span moves = stackalloc int[40]; + int written = PawnMovement.WriteMoves(ref position, Colors.White, moves, 0); + Assert.That(written, Is.EqualTo(16)); #endregion #region Assert by move equals - HashSet movesSet = UnsafeArrays.ToHashSet(moves, written); + HashSet movesSet = moves[..written].ToArray().ToHashSet(); int a2a4 = BinaryMoveOps.EncodeMove( Squares.a2, Pieces.Pawn, Colors.White, diff --git a/src/ChessRealms.ChessEngine.Tests/Parsing/StrictFenTests.cs b/src/ChessRealms.ChessEngine.Tests/Parsing/StrictFenTests.cs new file mode 100644 index 0000000..1a28642 --- /dev/null +++ b/src/ChessRealms.ChessEngine.Tests/Parsing/StrictFenTests.cs @@ -0,0 +1,141 @@ +using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Types; +using ChessRealms.ChessEngine.Core.Movements; +using ChessRealms.ChessEngine.Parsing; + +namespace ChessRealms.ChessEngine.Tests.Parsing; + +internal class StrictFenTests +{ + [TestCase(null)] + [TestCase("")] + [TestCase("8/8/8/8/8/8/8 w - - 0 1")] + [TestCase("8/8/8/8/8/8/8/8/8 w - - 0 1")] + [TestCase("88888888 w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/8K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/6K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/61K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K/ w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/0K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7X w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K x - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K W - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 0")] + [TestCase("7k/8/8/8/8/8/8/7K w - - -1 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 -1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - +1 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 +1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 1.0 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - ١ 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1 extra")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1 ")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K\tw - - 0 1")] + [TestCase("8/8/8/8/8/8/8/8 w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/8 w - - 0 1")] + [TestCase("7k/8/8/8/8/8/7K/7K w - - 0 1")] + [TestCase("7k/7K/8/8/8/8/8/8 w - - 0 1")] + [TestCase("7k/8/8/8/8/8/8/P6K w - - 0 1")] + [TestCase("p6k/8/8/8/8/8/8/7K w - - 0 1")] + [TestCase("7k/8/8/8/8/P7/PPPPPPPP/7K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/PPPPPPPP/QQ5K w - - 0 1")] + [TestCase("7k/8/8/8/8/8/PPPPPPPP/2B1B2K w - - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/K3R3 w - - 0 1")] + [TestCase("r3k3/8/8/8/8/8/8/K3R3 w - - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K3 w K - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K3 w Q - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K3 b k - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K3 b q - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/3K3R w K - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K2R w KK - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/R3K2R w QK - 0 1")] + [TestCase("4k3/8/8/8/8/8/8/4K2R w K- - 0 1")] + [TestCase("7k/8/8/3pP3/8/8/8/7K b - d6 0 1")] + [TestCase("7k/8/8/8/3Pp3/8/8/7K w - d3 0 1")] + [TestCase("7k/8/8/4P3/8/8/8/7K w - d6 0 1")] + [TestCase("7k/8/3n4/3pP3/8/8/8/7K w - d6 0 1")] + [TestCase("7k/3n4/8/3pP3/8/8/8/7K w - d6 0 1")] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 1 1")] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d5 0 1")] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6x 0 1")] + public void InvalidFen_IsRejectedWithoutPartialPosition(string? fen) + { + Assert.That(FenStrings.TryParse(fen, out var position), Is.False); + Assert.That(ChessGame.TryCreateFromFen(fen, out var game), Is.False); + Assert.That(game, Is.Null); + Assert.Throws(() => new ChessGame(position)); + } + + [TestCase("7k/8/8/8/8/8/8/7K w - - 9223372036854775808 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 9223372036854775808")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 999999999999999999999999999999999999 1")] + [TestCase(FenStrings.StartPosition)] + [TestCase("7k/8/8/3p4/8/8/8/7K w - d6 0 1")] + [TestCase("7k/8/8/8/3P4/8/8/7K b - d3 0 1")] + [TestCase("k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 9223372036854775807 9223372036854775807")] + [TestCase("7k/8/8/8/8/8/8/7K b - - 2147483648 2147483648")] + [TestCase("4k3/8/8/8/8/8/8/K3R3 b - - 0 1")] + public void ValidFen_RoundTripsExactly(string fen) + { + Assert.That(FenStrings.TryParse(fen, out var position), Is.True); + Assert.That(FenStrings.Format(position), Is.EqualTo(fen)); + Assert.That(new ChessGame(position).ToFen(), Is.EqualTo(fen)); + } + + [Test] + public void Fen_LeadingZeroCountersAreCanonicalized() + { + Assert.That(FenStrings.TryParse("7k/8/8/8/8/8/8/7K w - - 000 001", out var p), Is.True); + Assert.That(FenStrings.Format(p), Is.EqualTo("7k/8/8/8/8/8/8/7K w - - 0 1")); + } + + [Test] + public void PublicPositionEntryPoints_RejectMalformedState() + { + Assert.Throws(() => new ChessGame(default(Position))); + Assert.Throws(() => FenStrings.Format(default)); + Assert.Throws(() => new Position().IsKingChecked()); + Assert.Throws(() => Position.CreateDefault().GetPieceAt(64, Colors.White)); + Assert.Throws(() => Position.CreateDefault().GetPieceAt(0, 3)); + Assert.Throws(() => new ChessGame().GetBoardToSpan(new ChessPiece[63])); + var p = Position.CreateDefault(); + p.SetPieceAt(Squares.a2, Pieces.Queen, Colors.White); + Assert.Throws(() => new ChessGame(p)); + p = WithInvalidBlockers(); + Assert.Throws(() => new ChessGame(p)); + } + + private static unsafe Position WithInvalidBlockers() + { + var p = Position.CreateDefault(); + p.blockers[0] = 0; + return p; + } + + [Test] + public unsafe void BoundedMoveBuffer_ThrowsBeforeWritingOutsideSpan() + { + var p = Position.CreateDefault(); + var buffer = Enumerable.Repeat(-123, 4).ToArray(); + // A caller-provided short span remains safe even for a valid position. + Assert.Throws(() => + { + var local = p; + MoveGen.WriteMoves(ref local, local.color, buffer.AsSpan(1, 2)); + }); + Assert.That(buffer[0], Is.EqualTo(-123)); + Assert.That(buffer[3], Is.EqualTo(-123)); + } + + [Test] + public unsafe void CastlingGenerator_DoesNotCreateAMissingRook_EvenForInternalMalformedState() + { + var p = Position.CreateDefault(); + p.PopPieceAt(Squares.h1, Pieces.Rook, Colors.White); + p.PopPieceAt(Squares.f1, Pieces.Bishop, Colors.White); + p.PopPieceAt(Squares.g1, Pieces.Knight, Colors.White); + Span moves = stackalloc int[2]; + Assert.That(CastlingMovement.WriteMoves(ref p, Colors.White, moves), Is.Zero); + } +} diff --git a/src/ChessRealms.ChessEngine/AlgebraicMove.cs b/src/ChessRealms.ChessEngine/AlgebraicMove.cs index affdd16..d6135df 100644 --- a/src/ChessRealms.ChessEngine/AlgebraicMove.cs +++ b/src/ChessRealms.ChessEngine/AlgebraicMove.cs @@ -1,27 +1,24 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Parsing; namespace ChessRealms.ChessEngine; -public readonly struct AlgebraicMove(Square src, Square trg) +/// A coordinate move. Promotion must be explicit; no implicit queen promotion. +public readonly record struct AlgebraicMove(Square Src, Square Trg, PieceValue Promotion = PieceValue.None) { - public readonly Square Src = src; - public readonly Square Trg = trg; - + public Square Source => Src; + public Square Target => Trg; public static readonly AlgebraicMove Empty = new(Squares.Empty, Squares.Empty); - - public bool IsValid() - { - return Squares.IsValid(Src) && Squares.IsValid(Trg); - } - - public static AlgebraicMove Parse(ReadOnlySpan span) - { - return AlgebraicNotation.ParseAlgebraicMove(span); - } - + public bool IsValid() => Squares.IsValid(Src) && Squares.IsValid(Trg) && (int)Src != (int)Trg + && Promotion is PieceValue.None or PieceValue.Queen or PieceValue.Rook or PieceValue.Bishop or PieceValue.Knight; + public static AlgebraicMove Parse(ReadOnlySpan span) => AlgebraicNotation.ParseAlgebraicMove(span); public static bool TryParse(ReadOnlySpan span, out AlgebraicMove move) - { - return AlgebraicNotation.TryParseAlgebraicMove(span, out move); - } + => AlgebraicNotation.TryParseAlgebraicMove(span, out move); + public override string ToString() => !IsValid() ? "" : + SquareOps.ToAbbreviature(Src) + SquareOps.ToAbbreviature(Trg) + (Promotion switch + { + PieceValue.Queen => "q", PieceValue.Rook => "r", PieceValue.Bishop => "b", + PieceValue.Knight => "n", _ => "" + }); } diff --git a/src/ChessRealms.ChessEngine/ChessGame.cs b/src/ChessRealms.ChessEngine/ChessGame.cs index 4d9c103..171a762 100644 --- a/src/ChessRealms.ChessEngine/ChessGame.cs +++ b/src/ChessRealms.ChessEngine/ChessGame.cs @@ -1,196 +1,212 @@ -using ChessRealms.ChessEngine.Core.Constants; -using ChessRealms.ChessEngine.Core.Extensions; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Movements; using ChessRealms.ChessEngine.Core.Types; using ChessRealms.ChessEngine.Parsing; +using System.Numerics; namespace ChessRealms.ChessEngine; -public unsafe struct ChessGame +/// A mutable game with exclusive history ownership. Use Clone for independent analysis. +public sealed class ChessGame { private Position position; - - public readonly PieceColor CurrentColor => (PieceColor)position.color; - - public readonly PieceColor EnemyColor => (PieceColor)Colors.Mirror(position.color); - - public bool IsFinished { get; private set; } - - /// - /// Creates default chess board with init position setup. - /// - public ChessGame() : this(Position.CreateDefault()) - { - } + private readonly List history = []; + private readonly List undo = []; + private readonly List keys = []; + private readonly Dictionary repetitions = new(StringComparer.Ordinal); + + public PieceColor CurrentColor => (PieceColor)position.color; + public PieceColor EnemyColor => (PieceColor)Colors.Mirror(position.color); + public Position Position => position; + public BigInteger HalfmoveClock => position.halfMoveClock; + public BigInteger FullmoveNumber => position.fullMoveCount; + public GameOutcome Outcome { get; private set; } = GameOutcome.Ongoing; + public bool IsFinished => Outcome.Result != GameResult.Ongoing; + public bool IsInCheck => position.IsKingChecked(); + public GameState State => IsFinished ? GameState.Finished : IsInCheck ? GameState.Check : GameState.Active; + public int RepetitionCount => repetitions[keys[^1]]; + public IReadOnlyList History => Array.AsReadOnly(history.ToArray()); + public DrawClaim AvailableDrawClaims => IsFinished ? DrawClaim.None : Claims(position, RepetitionCount); + + public ChessGame() : this(Position.CreateDefault()) { } public ChessGame(Position position) { + if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); this.position = position; + var moves = LegalMoves(position); + string key = RepetitionKey(position, moves); + keys.Add(key); + repetitions.Add(key, 1); + Outcome = Evaluate(position, moves.Count, 1); } - public readonly void GetBoardToSpan(Span destination) + private ChessGame(ChessGame original) { - ulong allBlockers; - ulong whiteBlockers; - - fixed (Position* position = &this.position) - { - allBlockers = position->blockers[BitboardIndicies.AllBlockers]; - whiteBlockers = position->blockers[BitboardIndicies.WBlockers]; - } - - for (int i = 0; i < 64; ++i) - { - if (BitboardOps.GetBitAt(allBlockers, i).IsTrue()) - { - Piece piece; - if (BitboardOps.GetBitAt(whiteBlockers, i).IsTrue()) - piece = position.GetPieceAt(square: i, Colors.White); - else - piece = position.GetPieceAt(square: i, Colors.Black); - - destination[i] = new ChessPiece( - (PieceColor)piece.Color, - (PieceValue)piece.Value); - } - else - { - destination[i] = ChessPiece.Empty; - } - } + position = original.position; + Outcome = original.Outcome; + history.AddRange(original.history); + undo.AddRange(original.undo); + keys.AddRange(original.keys); + foreach (var pair in original.repetitions) repetitions.Add(pair.Key, pair.Value); } - public MoveResult MakeMove(in AlgebraicMove algebraicMove) + public ChessGame Clone() => new(this); + public string ToFen() => FenStrings.FormatUnchecked(position); + + public void GetBoardToSpan(Span destination) { - if (!algebraicMove.IsValid()) + if (destination.Length < 64) throw new ArgumentException("Board requires 64 squares.", nameof(destination)); + for (int i = 0; i < 64; i++) { - return MoveResult.None; + var piece = position.GetPieceAt(i, Colors.White); + if (!Piece.IsValid(piece)) piece = position.GetPieceAt(i, Colors.Black); + destination[i] = Piece.IsValid(piece) ? new((PieceColor)piece.Color, (PieceValue)piece.Value) : ChessPiece.Empty; } + } - Position positionBackup; - position.CopyTo(&positionBackup); - - int* moves = stackalloc int[218]; - int written; - - fixed (Position* position = &this.position) - written = MoveGen.WriteMovesToPtrUnsafe(position, position->color, moves); - - int move = BinaryMoveOps.NoneMove; - for (int i = 0; i < written; ++i) - { - int mSrc = BinaryMoveOps.DecodeSrc(moves[i]); - int mTrg = BinaryMoveOps.DecodeTrg(moves[i]); - - if (algebraicMove.Src == mSrc && algebraicMove.Trg == mTrg) - { - MoveDriver.MakeMove(ref position, moves[i]); + /// Snapshot of playable moves; empty after completion. Does not mutate the game. + public IReadOnlyList GetLegalMoves() => IsFinished + ? Array.Empty() + : Array.AsReadOnly(LegalMoves(position).Select(ToPublicMove).ToArray()); - if (position.IsKingChecked()) - { - fixed (Position* position = &this.position) - positionBackup.CopyTo(position); + public bool HasMoves() => !IsFinished && LegalMoves(position).Count != 0; - break; - } + public MoveResult MakeMove(in AlgebraicMove move) + { + if (IsFinished || !TryFindMove(move, out int encoded)) return MoveResult.None; + var next = position; + MoveDriver.MakeMove(ref next, encoded); + next.SwitchColor(); + var replies = LegalMoves(next); + string key = RepetitionKey(next, replies); + int count = repetitions.GetValueOrDefault(key) + 1; + var outcome = Evaluate(next, replies.Count, count); + MoveResult result = MoveResult.Move; + if (BinaryMoveOps.DecodeCapture(encoded) != 0) result |= MoveResult.Capture; + if (next.IsKingChecked()) result |= MoveResult.Check; + if (outcome.Reason == FinishReason.Checkmate) result |= MoveResult.Checkmate; + if (outcome.Reason == FinishReason.Stalemate) result |= MoveResult.Stalemate; + + var entry = new MoveHistoryEntry(move, ToFen(), FenStrings.FormatUnchecked(next), result); + undo.Add(position); + history.Add(entry); + keys.Add(key); + repetitions[key] = count; + position = next; + Outcome = outcome; + return result; + } - move = moves[i]; - } - } + /// Undo the last successful move, also clearing a later draw claim. + public bool UndoMove() + { + if (undo.Count == 0) return false; + string key = keys[^1]; + if (--repetitions[key] == 0) repetitions.Remove(key); + keys.RemoveAt(keys.Count - 1); + position = undo[^1]; + undo.RemoveAt(undo.Count - 1); + history.RemoveAt(history.Count - 1); + Outcome = Evaluate(position, LegalMoves(position).Count, RepetitionCount); + return true; + } - if (move == BinaryMoveOps.NoneMove) - { - return MoveResult.None; - } + /// Claims available after an intended legal move, without executing it. + public DrawClaim GetAvailableDrawClaims(in AlgebraicMove intendedMove) + { + if (IsFinished || !TryFindMove(intendedMove, out int encoded)) return DrawClaim.None; + var next = position; + MoveDriver.MakeMove(ref next, encoded); + next.SwitchColor(); + var moves = LegalMoves(next); + return Claims(next, repetitions.GetValueOrDefault(RepetitionKey(next, moves)) + 1); + } - var moveResult = MoveResult.Move; + /// A valid intended-move claim ends the game at its current board; the move is not played. + public bool ClaimDraw(DrawClaim reason, AlgebraicMove? intendedMove = null) + { + if (reason is not (DrawClaim.ThreefoldRepetition or DrawClaim.FiftyMoveRule)) return false; + DrawClaim available = intendedMove is { } move ? GetAvailableDrawClaims(move) : AvailableDrawClaims; + if ((available & reason) == 0) return false; + Outcome = Draw(reason == DrawClaim.ThreefoldRepetition ? FinishReason.ThreefoldRepetition : FinishReason.FiftyMoveRule); + return true; + } - if (BinaryMoveOps.DecodeCapture(move).IsTrue()) + private bool TryFindMove(AlgebraicMove move, out int encoded) + { + encoded = 0; + if (!move.IsValid()) return false; + foreach (int candidate in LegalMoves(position)) { - moveResult |= MoveResult.Capture; + if (ToPublicMove(candidate) != move) continue; + encoded = candidate; + return true; } + return false; + } - int enemyColor = Colors.Mirror(position.color); - fixed (Position* position = &this.position) - written = MoveGen.WriteMovesToPtrUnsafe(position, enemyColor, moves); + private static AlgebraicMove ToPublicMove(int move) => new(BinaryMoveOps.DecodeSrc(move), + BinaryMoveOps.DecodeTrg(move), BinaryMoveOps.DecodePromotion(move) == Promotions.None + ? PieceValue.None : (PieceValue)BinaryMoveOps.DecodePromotion(move)); - if (!HasMoves(enemyColor)) - { - IsFinished = true; - - if (position.IsKingChecked(enemyColor)) - moveResult |= MoveResult.Checkmate; - else - moveResult |= MoveResult.Stalemate; - } - else + private static List LegalMoves(Position p) + { + Span buffer = stackalloc int[MoveGen.MaxMoves]; + int written = MoveGen.WriteMoves(ref p, p.color, buffer); + List legal = []; + for (int i = 0; i < written; i++) { - if (position.IsKingChecked(enemyColor)) - moveResult |= MoveResult.Check; - - position.SwitchColor(); + var next = p; + // Move legality is independent of FEN counters. + MoveDriver.MakeMove(ref next, buffer[i], updateCounters: false); + if (!next.IsKingChecked(p.color)) legal.Add(buffer[i]); } - - return moveResult; + return legal; } - public bool HasMoves() + private static string RepetitionKey(Position p, List moves) { - return HasMoves(position.color); + if (!moves.Any(m => BinaryMoveOps.DecodeEnpassant(m) != 0)) p.enpassant = Squares.Empty; + string fen = FenStrings.FormatUnchecked(p); + return fen[..fen.LastIndexOf(' ', fen.LastIndexOf(' ') - 1)]; } - public bool HasMoves(PieceColor color) - { - if (!color.IsBlackOrWhite()) - return false; - - return HasMoves((int)color); - } + private static DrawClaim Claims(Position p, int count) => + (count >= 3 ? DrawClaim.ThreefoldRepetition : DrawClaim.None) + | (p.halfMoveClock >= 100 ? DrawClaim.FiftyMoveRule : DrawClaim.None); - private bool HasMoves(int color) + private static GameOutcome Evaluate(Position p, int legalCount, int repetitions) { - int* moves = stackalloc int[218]; - int written; - - fixed (Position* position = &this.position) - written = MoveGen.WriteMovesToPtrUnsafe(position, color, moves); - - Position tmpPosition; - - for (int i = 0; i < written; ++i) + if (legalCount == 0) { - position.CopyTo(&tmpPosition); - MoveDriver.MakeMove(ref tmpPosition, moves[i]); - - if (!tmpPosition.IsKingChecked(color)) - { - return true; - } + if (!p.IsKingChecked()) return Draw(FinishReason.Stalemate); + PieceColor winner = (PieceColor)Colors.Mirror(p.color); + return new(winner == PieceColor.White ? GameResult.WhiteWin : GameResult.BlackWin, winner, FinishReason.Checkmate); } - - return false; + if (IsBasicDeadPosition(p)) return Draw(FinishReason.DeadPosition); + if (repetitions >= 5) return Draw(FinishReason.FivefoldRepetition); + if (p.halfMoveClock >= 150) return Draw(FinishReason.SeventyFiveMoveRule); + return GameOutcome.Ongoing; } - /// - /// Try create chess game from FEN string. Creates default chess game if FEN is invalid. - /// - /// FEN string. - /// Created chess game. - /// - public static bool TryCreateFromFen(string fen, out ChessGame chessGame) - { - var parsed = FenStrings.TryParse(fen, out Position position); + private static GameOutcome Draw(FinishReason reason) => new(GameResult.Draw, PieceColor.None, reason); - if (parsed) - { - chessGame = new ChessGame(position); - } - else - { - chessGame = new ChessGame(); - } + private static bool IsBasicDeadPosition(Position p) + { + if ((p.pieceBBs[0] | p.pieceBBs[6] | p.pieceBBs[3] | p.pieceBBs[9] + | p.pieceBBs[4] | p.pieceBBs[10]) != 0) return false; + ulong knights = p.pieceBBs[1] | p.pieceBBs[7]; + ulong bishops = p.pieceBBs[2] | p.pieceBBs[8]; + if (BitOperations.PopCount(knights | bishops) <= 1) return true; + // Bishops alone, all on one square color. Never infer deadness from inability to force mate. + return knights == 0 && ((bishops & 0x55aa55aa55aa55aaUL) == 0 || (bishops & 0xaa55aa55aa55aa55UL) == 0); + } - return parsed; + public static bool TryCreateFromFen(string? fen, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChessGame? chessGame) + { + chessGame = FenStrings.TryParse(fen, out var position) ? new ChessGame(position) : null; + return chessGame is not null; } } diff --git a/src/ChessRealms.ChessEngine/Core/Movements/CastlingMovement.cs b/src/ChessRealms.ChessEngine/Core/Movements/CastlingMovement.cs index b008258..16bebdb 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/CastlingMovement.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/CastlingMovement.cs @@ -1,23 +1,25 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Types; namespace ChessRealms.ChessEngine.Core.Movements; -internal static unsafe class CastlingMovement +internal static class CastlingMovement { - public static int WriteMovesToUnsafePtr(Position* position, int color, int* dest, int offset = 0) + public static int WriteMoves(ref Position position, int color, Span dest, int offset = 0) { int cursor = offset; if (color == Colors.Black) { - bool BK_CastlingAvailable = (position->castlings & Castlings.BK) != 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.f8) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.g8) == 0 - && !position->IsSquareAttackedByWhite(Squares.e8) - && !position->IsSquareAttackedByWhite(Squares.f8) - && !position->IsSquareAttackedByWhite(Squares.g8); + bool BK_CastlingAvailable = (position.castlings & Castlings.BK) != 0 + && position.GetPieceAt(Squares.e8, Colors.Black).Value == Pieces.King + && position.GetPieceAt(Squares.h8, Colors.Black).Value == Pieces.Rook + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.f8) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.g8) == 0 + && !position.IsSquareAttackedByWhite(Squares.e8) + && !position.IsSquareAttackedByWhite(Squares.f8) + && !position.IsSquareAttackedByWhite(Squares.g8); if (BK_CastlingAvailable) { @@ -26,13 +28,15 @@ public static int WriteMovesToUnsafePtr(Position* position, int color, int* dest castling: Castlings.BK); } - bool BQ_CastlingAvailable = (position->castlings & Castlings.BQ) != 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.b8) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.c8) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.d8) == 0 - && !position->IsSquareAttackedByWhite(Squares.e8) - && !position->IsSquareAttackedByWhite(Squares.d8) - && !position->IsSquareAttackedByWhite(Squares.c8); + bool BQ_CastlingAvailable = (position.castlings & Castlings.BQ) != 0 + && position.GetPieceAt(Squares.e8, Colors.Black).Value == Pieces.King + && position.GetPieceAt(Squares.a8, Colors.Black).Value == Pieces.Rook + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.b8) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.c8) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.d8) == 0 + && !position.IsSquareAttackedByWhite(Squares.e8) + && !position.IsSquareAttackedByWhite(Squares.d8) + && !position.IsSquareAttackedByWhite(Squares.c8); if (BQ_CastlingAvailable) { @@ -43,12 +47,14 @@ public static int WriteMovesToUnsafePtr(Position* position, int color, int* dest } else { - bool WK_CastlingAvailable = (position->castlings & Castlings.WK) != 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.f1) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.g1) == 0 - && !position->IsSquareAttackedByBlack(Squares.e1) - && !position->IsSquareAttackedByBlack(Squares.f1) - && !position->IsSquareAttackedByBlack(Squares.g1); + bool WK_CastlingAvailable = (position.castlings & Castlings.WK) != 0 + && position.GetPieceAt(Squares.e1, Colors.White).Value == Pieces.King + && position.GetPieceAt(Squares.h1, Colors.White).Value == Pieces.Rook + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.f1) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.g1) == 0 + && !position.IsSquareAttackedByBlack(Squares.e1) + && !position.IsSquareAttackedByBlack(Squares.f1) + && !position.IsSquareAttackedByBlack(Squares.g1); if (WK_CastlingAvailable) { @@ -57,13 +63,15 @@ public static int WriteMovesToUnsafePtr(Position* position, int color, int* dest castling: Castlings.WK); } - bool WQ_CastlingAvailable = (position->castlings & Castlings.WQ) != 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.b1) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.c1) == 0 - && BitboardOps.GetBitAt(position->blockers[Colors.None], Squares.d1) == 0 - && !position->IsSquareAttackedByBlack(Squares.e1) - && !position->IsSquareAttackedByBlack(Squares.d1) - && !position->IsSquareAttackedByBlack(Squares.c1); + bool WQ_CastlingAvailable = (position.castlings & Castlings.WQ) != 0 + && position.GetPieceAt(Squares.e1, Colors.White).Value == Pieces.King + && position.GetPieceAt(Squares.a1, Colors.White).Value == Pieces.Rook + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.b1) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.c1) == 0 + && BitboardOps.GetBitAt(position.blockers[Colors.None], Squares.d1) == 0 + && !position.IsSquareAttackedByBlack(Squares.e1) + && !position.IsSquareAttackedByBlack(Squares.d1) + && !position.IsSquareAttackedByBlack(Squares.c1); if (WQ_CastlingAvailable) { diff --git a/src/ChessRealms.ChessEngine/Core/Movements/LeapingMovement.cs b/src/ChessRealms.ChessEngine/Core/Movements/LeapingMovement.cs index 2cd8b68..8f7e935 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/LeapingMovement.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/LeapingMovement.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Types; using ChessRealms.ChessEngine.Debugs; @@ -8,12 +8,12 @@ namespace ChessRealms.ChessEngine.Core.Movements; internal unsafe static class LeapingMovement { - public static int WriteMovesToPtrUnsafe( - Position* position, - int color, + public static int WriteMoves( + ref Position position, + int color, int piece, ulong* masksLookup, - int* dest, + Span dest, int offset = 0) { DebugHelper.Assert.IsValidColor(color); @@ -23,9 +23,9 @@ public static int WriteMovesToPtrUnsafe( int cursor = offset; int enemyColor = Colors.Mirror(color); - ulong myBlockers = position->blockers[color]; - ulong enemyBlockers = position->blockers[enemyColor]; - ulong pieceBB = position->pieceBBs[Position.BBIndex(piece, color)]; + ulong myBlockers = position.blockers[color]; + ulong enemyBlockers = position.blockers[enemyColor]; + ulong pieceBB = position.pieceBBs[Position.BBIndex(piece, color)]; int srcSquare; int trgSquare; @@ -42,7 +42,7 @@ public static int WriteMovesToPtrUnsafe( { trgSquare = BitboardOps.Lsb(captures); dest[cursor++] = BinaryMoveOps.EncodeMove( - srcSquare, piece, color, trgSquare, + srcSquare, piece, color, trgSquare, capture: 1); captures = BitboardOps.PopBitAt(captures, trgSquare); } diff --git a/src/ChessRealms.ChessEngine/Core/Movements/MoveDriver.cs b/src/ChessRealms.ChessEngine/Core/Movements/MoveDriver.cs index 94287d9..576b722 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/MoveDriver.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/MoveDriver.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Extensions; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Types; @@ -20,8 +20,15 @@ internal static class MoveDriver 7, 15, 15, 15, 3, 15, 15, 11 ]; - public static unsafe void MakeMove(ref Position position, int move) + public static void MakeMove(ref Position position, int move, bool updateCounters = true) { + if (updateCounters) + { + position.halfMoveClock = BinaryMoveOps.DecodeSrcPiece(move) == Pieces.Pawn + || BinaryMoveOps.DecodeCapture(move) != 0 ? 0 : position.halfMoveClock + 1; + if (BinaryMoveOps.DecodeSrcColor(move) == Colors.Black) + position.fullMoveCount = position.fullMoveCount + 1; + } position.enpassant = Squares.Empty; int castling = BinaryMoveOps.DecodeCastling(move); @@ -75,28 +82,8 @@ public static unsafe void MakeMove(ref Position position, int move) position.MovePiece(src, trg, srcColor, Pieces.Pawn); - #region Set EP - ulong enemyNeighborPawns; - int stepToBack; - - if (srcColor == Colors.White) - { - enemyNeighborPawns = position.pieceBBs[0] & SquareMapping.RANK_4; - stepToBack = Directions.South; - } - else - { - enemyNeighborPawns = position.pieceBBs[6] & SquareMapping.RANK_5; - stepToBack = Directions.North; - } - - ulong epMask = SquareOps.ToBitboard(trg - 1) | SquareOps.ToBitboard(trg + 1); - - if ((epMask & enemyNeighborPawns) != 0) - { - position.enpassant = trg + stepToBack; - } - #endregion + // Standard FEN records the target even when no capture is available. + position.enpassant = (src + trg) / 2; } else { diff --git a/src/ChessRealms.ChessEngine/Core/Movements/MoveGen.cs b/src/ChessRealms.ChessEngine/Core/Movements/MoveGen.cs index 5c79dac..b87dde4 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/MoveGen.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/MoveGen.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Attacks; +using ChessRealms.ChessEngine.Core.Attacks; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Types; @@ -6,33 +6,38 @@ namespace ChessRealms.ChessEngine.Core.Movements; internal static unsafe class MoveGen { - public static int WriteMovesToPtrUnsafe(Position* position, int color, int* moves, int offset = 0) + // At most 16 pieces/side in validated standard positions. Each has at most + // 27 queen destinations (a pawn has at most 12 promotion variants), plus + // two castles. Span indexing also checks every write, even for internal fixtures. + internal const int MaxMoves = 16 * 27 + 2; + + public static int WriteMoves(ref Position position, int color, Span moves, int offset = 0) { int cursor = offset; - cursor += PawnMovement.WriteMovesToPtrUnsafe(position, color, moves, cursor); - - cursor += LeapingMovement.WriteMovesToPtrUnsafe( - position, color, Pieces.Knight, + cursor += PawnMovement.WriteMoves(ref position, color, moves, cursor); + + cursor += LeapingMovement.WriteMoves( + ref position, color, Pieces.Knight, KnightAttacks.AttackMasksPtr, moves, cursor); - - cursor += SlidingMovement.WriteMovesToPtrUnsafe( - position, color, Pieces.Bishop, + + cursor += SlidingMovement.WriteMoves( + ref position, color, Pieces.Bishop, &BishopAttacks.GetSliderAttack, moves, cursor); - cursor += SlidingMovement.WriteMovesToPtrUnsafe( - position, color, Pieces.Rook, + cursor += SlidingMovement.WriteMoves( + ref position, color, Pieces.Rook, &RookAttacks.GetSliderAttack, moves, cursor); - - cursor += SlidingMovement.WriteMovesToPtrUnsafe( - position, color, Pieces.Queen, + + cursor += SlidingMovement.WriteMoves( + ref position, color, Pieces.Queen, &QueenAttacks.GetSliderAttack, moves, cursor); - - cursor += LeapingMovement.WriteMovesToPtrUnsafe( - position, color, Pieces.King, + + cursor += LeapingMovement.WriteMoves( + ref position, color, Pieces.King, KingAttacks.AttackMasksPtr, moves, cursor); - cursor += CastlingMovement.WriteMovesToUnsafePtr(position, color, moves, cursor); + cursor += CastlingMovement.WriteMoves(ref position, color, moves, cursor); return cursor - offset; } diff --git a/src/ChessRealms.ChessEngine/Core/Movements/PawnMovement.cs b/src/ChessRealms.ChessEngine/Core/Movements/PawnMovement.cs index 9f724a4..177f47c 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/PawnMovement.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/PawnMovement.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Attacks; +using ChessRealms.ChessEngine.Core.Attacks; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Extensions; using ChessRealms.ChessEngine.Core.Math; @@ -9,7 +9,7 @@ namespace ChessRealms.ChessEngine.Core.Movements; -internal unsafe static class PawnMovement +internal static class PawnMovement { public const int HorizontalRotateStep = 8; @@ -25,20 +25,20 @@ public static ulong South(ulong bitboard) return bitboard >> HorizontalRotateStep; } - public static int WriteMovesToPtrUnsafe(Position* position, int color, int* dest, int offset = 0) + public static int WriteMoves(ref Position position, int color, Span dest, int offset = 0) { DebugHelper.Assert.IsValidColor(color); Debug.Assert(offset >= 0); - + int cursor = offset; int BBIndex = Position.BBIndex(Pieces.Pawn, color); - ulong empty = ~position->blockers[Colors.None]; - ulong pawns = position->pieceBBs[BBIndex]; - int enpassant = position->enpassant; + ulong empty = ~position.blockers[Colors.None]; + ulong pawns = position.pieceBBs[BBIndex]; + int enpassant = position.enpassant; int enemyColor = Colors.Mirror(color); - ulong enemyPieces = position->blockers[enemyColor]; + ulong enemyPieces = position.blockers[enemyColor]; ulong singlePush; ulong doublePush; @@ -99,7 +99,7 @@ public static int WriteMovesToPtrUnsafe(Position* position, int color, int* dest int srcSquare = trgSquare + (2 * stepBack); dest[cursor++] = BinaryMoveOps.EncodeMove( - srcSquare, Pieces.Pawn, color, trgSquare, + srcSquare, Pieces.Pawn, color, trgSquare, doublePush: 1); BitboardOps.PopBitAt(ref doublePush, trgSquare); diff --git a/src/ChessRealms.ChessEngine/Core/Movements/SlidingMovement.cs b/src/ChessRealms.ChessEngine/Core/Movements/SlidingMovement.cs index fd41f7d..99cfd89 100644 --- a/src/ChessRealms.ChessEngine/Core/Movements/SlidingMovement.cs +++ b/src/ChessRealms.ChessEngine/Core/Movements/SlidingMovement.cs @@ -1,4 +1,4 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Types; using ChessRealms.ChessEngine.Debugs; @@ -8,12 +8,12 @@ namespace ChessRealms.ChessEngine.Core.Movements; internal unsafe static class SlidingMovement { - public static int WriteMovesToPtrUnsafe( - Position* position, + public static int WriteMoves( + ref Position position, int color, int piece, delegate* getSlidingMaskFunc, - int* dest, + Span dest, int offset = 0) { DebugHelper.Assert.IsValidColor(color); @@ -23,10 +23,10 @@ public static int WriteMovesToPtrUnsafe( int cursor = offset; int enemyColor = Colors.Mirror(color); - ulong myBlockers = position->blockers[color]; - ulong enemyBlockers = position->blockers[enemyColor]; + ulong myBlockers = position.blockers[color]; + ulong enemyBlockers = position.blockers[enemyColor]; ulong allBlockers = myBlockers | enemyBlockers; - ulong pieceBB = position->pieceBBs[Position.BBIndex(piece, color)]; + ulong pieceBB = position.pieceBBs[Position.BBIndex(piece, color)]; int srcSquare; int trgSquare; @@ -43,7 +43,7 @@ public static int WriteMovesToPtrUnsafe( { trgSquare = BitboardOps.Lsb(captures); dest[cursor++] = BinaryMoveOps.EncodeMove( - srcSquare, piece, color, trgSquare, + srcSquare, piece, color, trgSquare, capture: 1); BitboardOps.PopBitAt(ref captures, trgSquare); } diff --git a/src/ChessRealms.ChessEngine/Core/Types/Position.cs b/src/ChessRealms.ChessEngine/Core/Types/Position.cs index 05fd138..84befb4 100644 --- a/src/ChessRealms.ChessEngine/Core/Types/Position.cs +++ b/src/ChessRealms.ChessEngine/Core/Types/Position.cs @@ -1,43 +1,66 @@ -using ChessRealms.ChessEngine.Core.Attacks; +using ChessRealms.ChessEngine.Core.Attacks; using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Extensions; using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Debugs; using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using System.Numerics; namespace ChessRealms.ChessEngine.Core.Types; -[StructLayout(LayoutKind.Sequential)] -public unsafe struct Position + +/// A value snapshot. Assignment copies bitboards; its BigInteger counters are immutable. +public struct Position : IEquatable { - internal fixed ulong pieceBBs[12]; - internal fixed ulong blockers[3]; + internal PieceBoards pieceBBs; + internal OccupancyBoards blockers; internal int color; internal int castlings; internal int enpassant; - internal int fullMoveCount; - internal int halfMoveClock; + internal BigInteger fullMoveCount; + internal BigInteger halfMoveClock; + + public bool Equals(Position other) + { + for (int i = 0; i < 12; i++) if (pieceBBs[i] != other.pieceBBs[i]) return false; + for (int i = 0; i < 3; i++) if (blockers[i] != other.blockers[i]) return false; + return color == other.color && castlings == other.castlings && enpassant == other.enpassant + && fullMoveCount == other.fullMoveCount && halfMoveClock == other.halfMoveClock; + } + + public override bool Equals(object? obj) => obj is Position other && Equals(other); + public override int GetHashCode() + { + HashCode hash = new(); + for (int i = 0; i < 12; i++) hash.Add(pieceBBs[i]); + for (int i = 0; i < 3; i++) hash.Add(blockers[i]); + hash.Add(color); hash.Add(castlings); hash.Add(enpassant); + hash.Add(fullMoveCount); hash.Add(halfMoveClock); + return hash.ToHashCode(); + } + + public static bool operator ==(Position left, Position right) => left.Equals(right); + public static bool operator !=(Position left, Position right) => !left.Equals(right); public Position() { color = Colors.White; castlings = Castlings.None; enpassant = Squares.Empty; - fullMoveCount = 0; - halfMoveClock = 1; + fullMoveCount = 1; + halfMoveClock = 0; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SwitchColor() => color = Colors.Mirror(color); + internal void SwitchColor() => color = Colors.Mirror(color); public Piece GetPieceAt(int square, int color) { - DebugHelper.Assert.IsValidSquare(square); - DebugHelper.Assert.IsValidColor(color); + if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); int bbIndex = BBIndex(Pieces.Pawn, color); int bbLastIndex = BBIndex(Pieces.King, color); @@ -59,11 +82,11 @@ public Piece GetPieceAt(int square, int color) return Piece.Empty; } - public void SetPieceAt(int square, int piece, int color) + internal void SetPieceAt(int square, int piece, int color) { - DebugHelper.Assert.IsValidSquare(square); + if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); DebugHelper.Assert.IsValidPiece(piece); - DebugHelper.Assert.IsValidColor(color); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); int bbIndex = BBIndex(piece, color); Debug.Assert(IsValidBBIndex(bbIndex)); @@ -73,11 +96,11 @@ public void SetPieceAt(int square, int piece, int color) BitboardOps.SetBitAt(ref blockers[BitboardIndicies.AllBlockers], square); } - public void PopPieceAt(int square, int piece, int color) + internal void PopPieceAt(int square, int piece, int color) { - DebugHelper.Assert.IsValidSquare(square); + if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); DebugHelper.Assert.IsValidPiece(piece); - DebugHelper.Assert.IsValidColor(color); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); int bbIndex = BBIndex(piece, color); Debug.Assert(IsValidBBIndex(bbIndex)); @@ -87,10 +110,10 @@ public void PopPieceAt(int square, int piece, int color) BitboardOps.PopBitAt(ref blockers[BitboardIndicies.AllBlockers], square); } - public void PopPieceAt(int square, int color) + internal void PopPieceAt(int square, int color) { - DebugHelper.Assert.IsValidSquare(square); - DebugHelper.Assert.IsValidColor(color); + if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); int i = BBIndex(Pieces.Pawn, color); @@ -105,7 +128,7 @@ public void PopPieceAt(int square, int color) BitboardOps.PopBitAt(ref blockers[BitboardIndicies.AllBlockers], square); } - public void MovePiece(int srcSquare, int trgSquare, int color, int piece) + internal void MovePiece(int srcSquare, int trgSquare, int color, int piece) { PopPieceAt(srcSquare, piece, color); SetPieceAt(trgSquare, piece, color); @@ -115,7 +138,7 @@ public void MovePiece(int srcSquare, int trgSquare, int color, int piece) internal static int BBIndex(int piece, int color) { DebugHelper.Assert.IsValidPiece(piece); - DebugHelper.Assert.IsValidColor(color); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); return (color * 6) + piece; } @@ -124,7 +147,7 @@ internal static int BBIndex(int piece, int color) internal static int PieceFromBBIndex(int bbIndex, int color) { Debug.Assert(IsValidBBIndex(bbIndex)); - DebugHelper.Assert.IsValidColor(color); + if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); return bbIndex - color * 6; } @@ -135,9 +158,13 @@ private static bool IsValidBBIndex(int bbIndex) return bbIndex >= 0 && bbIndex < 12; } - public bool IsKingChecked() => IsKingChecked(color); + public bool IsKingChecked() + { + if (!PositionValidation.IsValid(this)) throw new InvalidOperationException("Invalid standard chess position."); + return IsKingChecked(color); + } - public bool IsKingChecked(int kingColor) + internal bool IsKingChecked(int kingColor) { if (kingColor == Colors.Black) { @@ -156,7 +183,7 @@ public bool IsKingChecked(int kingColor) // Should run a bit faster than universal version // that calculates Bitboard indicies. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsSquareAttackedByWhite(int square) + internal bool IsSquareAttackedByWhite(int square) { return IsAttackedByWhitePawn(square) || IsAttackedByWhiteKnight(square) @@ -166,7 +193,7 @@ public bool IsSquareAttackedByWhite(int square) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsSquareAttackedByBlack(int square) + internal bool IsSquareAttackedByBlack(int square) { return IsAttackedByBlackPawn(square) || IsAttackedByBlackKnight(square) @@ -273,13 +300,7 @@ internal bool IsAttackedByBlackKing(int square) } #endregion - public unsafe void CopyTo(Position* dst) - { - fixed (Position* src = &this) - { - Buffer.MemoryCopy(src, dst, sizeof(Position), sizeof(Position)); - } - } + // Position assignment copies the inline bitboards; BigInteger is immutable. /// /// Creates default position filled with setuped pieces at its default positions. @@ -324,11 +345,16 @@ public static Position CreateDefault() position.blockers[BitboardIndicies.AllBlockers] = bAll | wAll; position.color = Colors.White; - position.castlings = Castlings.None; + position.castlings = Castlings.All; position.enpassant = Squares.Empty; - position.fullMoveCount = 0; - position.halfMoveClock = 1; + position.fullMoveCount = 1; + position.halfMoveClock = 0; return position; } -} \ No newline at end of file +} +[InlineArray(12)] +internal struct PieceBoards { private ulong first; } + +[InlineArray(3)] +internal struct OccupancyBoards { private ulong first; } diff --git a/src/ChessRealms.ChessEngine/Core/Types/PositionValidation.cs b/src/ChessRealms.ChessEngine/Core/Types/PositionValidation.cs new file mode 100644 index 0000000..cab6e6b --- /dev/null +++ b/src/ChessRealms.ChessEngine/Core/Types/PositionValidation.cs @@ -0,0 +1,61 @@ +using ChessRealms.ChessEngine.Core.Constants; +using System.Numerics; + +namespace ChessRealms.ChessEngine.Core.Types; + +/// Local standard-chess invariants, without proving historical reachability. +internal static class PositionValidation +{ + internal static bool IsValid(Position p) + { + if (!Colors.IsValid(p.color) || (p.castlings & ~Castlings.All) != 0 + || p.halfMoveClock < 0 || p.fullMoveCount < 1 + || (p.enpassant != Squares.Empty && !Squares.IsValid(p.enpassant))) return false; + ulong all = 0; + for (int color = 0; color < 2; color++) + { + ulong occupied = 0; + for (int piece = 0; piece < 6; piece++) + { + ulong bb = p.pieceBBs[color * 6 + piece]; + if ((all & bb) != 0) return false; + occupied |= bb; + all |= bb; + } + if (occupied != p.blockers[color] || BitOperations.PopCount(occupied) > 16) return false; + ulong pawns = p.pieceBBs[color * 6]; + int pawnCount = BitOperations.PopCount(pawns); + if (pawnCount > 8 || (pawns & 0xff000000000000ffUL) != 0 + || BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.King]) != 1) return false; + // Extra pieces require enough missing pawns to account for promotions. + int promotions = System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Knight]) - 2) + + System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Rook]) - 2) + + System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Queen]) - 1); + ulong bishops = p.pieceBBs[color * 6 + Pieces.Bishop]; + promotions += System.Math.Max(0, BitOperations.PopCount(bishops & 0x55aa55aa55aa55aaUL) - 1) + + System.Math.Max(0, BitOperations.PopCount(bishops & 0xaa55aa55aa55aa55UL) - 1); + if (promotions > 8 - pawnCount) return false; + } + if (all != p.blockers[Colors.None]) return false; + // Also excludes adjacent kings and positions where both kings are in check. + if (p.IsKingChecked(Colors.Mirror(p.color))) return false; + for (int i = 0; i < 4; i++) + { + if ((p.castlings & (1 << i)) == 0) continue; + int color = i < 2 ? Colors.White : Colors.Black; + int rank = i < 2 ? 0 : 56; + if (p.GetPieceAt(rank + 4, color).Value != Pieces.King + || p.GetPieceAt(rank + (i % 2 == 0 ? 7 : 0), color).Value != Pieces.Rook) return false; + } + if (p.enpassant != Squares.Empty) + { + int target = p.enpassant; + int direction = p.color == Colors.White ? -8 : 8; + if (target / 8 != (p.color == Colors.White ? 5 : 2) + || (all & (1UL << target)) != 0 || (all & (1UL << (target - direction))) != 0 + || p.GetPieceAt(target + direction, Colors.Mirror(p.color)).Value != Pieces.Pawn + || p.halfMoveClock != 0) return false; + } + return true; + } +} diff --git a/src/ChessRealms.ChessEngine/FinishReason.cs b/src/ChessRealms.ChessEngine/FinishReason.cs index 4105441..f70a269 100644 --- a/src/ChessRealms.ChessEngine/FinishReason.cs +++ b/src/ChessRealms.ChessEngine/FinishReason.cs @@ -1,11 +1,13 @@ -namespace ChessRealms.ChessEngine; +namespace ChessRealms.ChessEngine; public enum FinishReason { - None = 0, - Draw = 1, - Mate = 2, - VoteForDraw = 3, - Resign = 4, - Stalemate = 5 + None, + Checkmate, + Stalemate, + DeadPosition, + ThreefoldRepetition, + FiftyMoveRule, + FivefoldRepetition, + SeventyFiveMoveRule } diff --git a/src/ChessRealms.ChessEngine/GameOutcome.cs b/src/ChessRealms.ChessEngine/GameOutcome.cs new file mode 100644 index 0000000..c1fd98f --- /dev/null +++ b/src/ChessRealms.ChessEngine/GameOutcome.cs @@ -0,0 +1,14 @@ +namespace ChessRealms.ChessEngine; + +public enum GameState { Active, Check, Finished } +public enum GameResult { Ongoing, WhiteWin, BlackWin, Draw } + +[Flags] +public enum DrawClaim { None = 0, ThreefoldRepetition = 1, FiftyMoveRule = 2 } + +public readonly record struct GameOutcome(GameResult Result, PieceColor Winner, FinishReason Reason) +{ + public static GameOutcome Ongoing => new(GameResult.Ongoing, PieceColor.None, FinishReason.None); +} + +public readonly record struct MoveHistoryEntry(AlgebraicMove Move, string FenBefore, string FenAfter, MoveResult Result); diff --git a/src/ChessRealms.ChessEngine/Parsing/AlgebraicNotation.cs b/src/ChessRealms.ChessEngine/Parsing/AlgebraicNotation.cs index b5b8db9..ae6d1d3 100644 --- a/src/ChessRealms.ChessEngine/Parsing/AlgebraicNotation.cs +++ b/src/ChessRealms.ChessEngine/Parsing/AlgebraicNotation.cs @@ -1,78 +1,40 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; using ChessRealms.ChessEngine.Core.Math; -using System.Diagnostics; -using System.Runtime.CompilerServices; namespace ChessRealms.ChessEngine.Parsing; public static class AlgebraicNotation { - /// - /// - /// - /// - /// - public static AlgebraicMove ParseAlgebraicMove(ReadOnlySpan moveSpan) - { - Debug.Assert(moveSpan.Length > 3); - - int src = ParseSquare(moveSpan[..2]); - int trg = ParseSquare(moveSpan[2..]); - - return new(src, trg); - } + public static AlgebraicMove ParseAlgebraicMove(ReadOnlySpan text) + => TryParseAlgebraicMove(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); - /// - /// Try parse move. - /// - /// Move as text. Example: "a2a4". - /// Output move. - /// - /// if text was successfully parsed; otherwise, - /// - public static bool TryParseAlgebraicMove(ReadOnlySpan moveSpan, out AlgebraicMove move) + public static bool TryParseAlgebraicMove(ReadOnlySpan text, out AlgebraicMove move) { - bool validate = moveSpan.Length > 3 - && SquareOps.ValidateFile(moveSpan[0]) - && SquareOps.ValidateRank(moveSpan[1]) - && SquareOps.ValidateFile(moveSpan[2]) - && SquareOps.ValidateRank(moveSpan[3]); - - if (validate) + move = AlgebraicMove.Empty; + if (text.Length is not (4 or 5) || !TryParseSquare(text[..2], out int src) + || !TryParseSquare(text.Slice(2, 2), out int trg) || src == trg) return false; + PieceValue promotion = PieceValue.None; + if (text.Length == 5) { - move = new AlgebraicMove( - src: ParseSquare(moveSpan[..2]), - trg: ParseSquare(moveSpan[2..])); - return true; + promotion = text[4] switch + { + 'q' => PieceValue.Queen, 'r' => PieceValue.Rook, 'b' => PieceValue.Bishop, + 'n' => PieceValue.Knight, _ => PieceValue.None + }; + if (promotion == PieceValue.None) return false; } - - move = AlgebraicMove.Empty; - return false; + move = new(src, trg, promotion); + return true; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int ParseSquare(ReadOnlySpan fileRankSpan) - { - Debug.Assert(fileRankSpan.Length > 1); - Debug.Assert(SquareOps.ValidateFile(fileRankSpan[0])); - Debug.Assert(SquareOps.ValidateRank(fileRankSpan[1])); - - return SquareOps.FromFileRank(fileRankSpan[0] - 'a', fileRankSpan[1] - '1'); - } + public static int ParseSquare(ReadOnlySpan text) + => TryParseSquare(text, out int square) ? square : throw new FormatException("Expected a square a1 through h8."); - public static bool TryParseSquare(ReadOnlySpan fileRankSpan, out int square) + public static bool TryParseSquare(ReadOnlySpan text, out int square) { - bool validate = fileRankSpan.Length > 1 - && SquareOps.ValidateFile(fileRankSpan[0]) - && SquareOps.ValidateRank(fileRankSpan[1]); - - if (validate) - { - square = ParseSquare(fileRankSpan); - return true; - } - square = Squares.Empty; - return false; + if (text.Length != 2 || text[0] is < 'a' or > 'h' || text[1] is < '1' or > '8') return false; + square = SquareOps.FromFileRank(text[0] - 'a', text[1] - '1'); + return true; } } diff --git a/src/ChessRealms.ChessEngine/Parsing/FenStrings.cs b/src/ChessRealms.ChessEngine/Parsing/FenStrings.cs index ed4ce17..8f837d3 100644 --- a/src/ChessRealms.ChessEngine/Parsing/FenStrings.cs +++ b/src/ChessRealms.ChessEngine/Parsing/FenStrings.cs @@ -1,131 +1,122 @@ -using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Constants; +using ChessRealms.ChessEngine.Core.Math; using ChessRealms.ChessEngine.Core.Types; -using System.Text.RegularExpressions; +using System.Globalization; +using System.Numerics; +using System.Text; namespace ChessRealms.ChessEngine.Parsing; -public static partial class FenStrings +public static class FenStrings { public const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; - - public static bool TryParse(string fen, out Position position) - { - position = new(); - - Match match = FenRegex().Match(fen); - if (!match.Success) + public static bool TryParse(string? fen, out Position position) + { + if (TryParseSyntax(fen, out var candidate) && PositionValidation.IsValid(candidate)) { - return false; + position = candidate; + return true; } + position = new(); + return false; + } - var groups = match.Groups; - - ReadOnlySpan piecePlacementSpan = groups["PiecePlacement"].ValueSpan; - ReadOnlySpan sideToMoveSpan = groups["SideToMove"].ValueSpan; - ReadOnlySpan castlingSpan = groups["Castling"].ValueSpan; - ReadOnlySpan enPassantSpan = groups["EnPassant"].ValueSpan; - ReadOnlySpan halfMoveClockSpan = groups["HalfMoveClock"].ValueSpan; - ReadOnlySpan fullMoveNumberSpan = groups["FullMoveNumber"].ValueSpan; - - #region Read pieces - // Fen string represented as piece in next positions a8-h8/a7-h8/.../a1-h1. - // So we setup a8 as start index. - int squareIndex = Squares.a8; - - for (int i = 0; i < piecePlacementSpan.Length; ++i) + // Internal geometric move-generation fixtures may deliberately omit kings. + // This entry point must never be used to construct a public game. + internal static bool TryParseSyntax(string? fen, out Position position) + { + position = new(); + if (fen is null) return false; + string[] fields = fen.Split(' '); + if (fields.Length != 6 || fields.Any(string.IsNullOrEmpty)) return false; + var ranks = fields[0].Split('/'); + if (ranks.Length != 8) return false; + Position candidate = new(); + for (int rank = 0; rank < 8; rank++) { - if (!char.IsLetterOrDigit(piecePlacementSpan[i])) + int file = 0; + bool previousDigit = false; + foreach (char c in ranks[rank]) { - // Move to next rank. '-16' insted of '-8' related to ordering - // from 'a' to 'h' and next increments of 'squareIndex'. - squareIndex -= 16; - continue; - } - - if (char.IsDigit(piecePlacementSpan[i])) - { - int spaces = (int) char.GetNumericValue(piecePlacementSpan[i]); - squareIndex += spaces; - } - else - { - int color = char.IsUpper(piecePlacementSpan[i]) ? Colors.White : Colors.Black; - int piece = char.ToLower(piecePlacementSpan[i]) switch + if (c is >= '1' and <= '8') { - 'p' => Pieces.Pawn, - 'n' => Pieces.Knight, - 'b' => Pieces.Bishop, - 'r' => Pieces.Rook, - 'q' => Pieces.Queen, - 'k' => Pieces.King, - _ => Pieces.None - }; - - if (Pieces.IsValid(piece)) + if (previousDigit) return false; + file += c - '0'; + previousDigit = true; + } + else { - position.SetPieceAt(squareIndex, piece, color); + int piece = "pnbrqk".IndexOf(char.ToLowerInvariant(c)); + if (piece < 0 || !"pnbrqkPNBRQK".Contains(c) || file >= 8) return false; + candidate.SetPieceAt((7 - rank) * 8 + file++, piece, char.IsUpper(c) ? Colors.White : Colors.Black); + previousDigit = false; } - - ++squareIndex; + if (file > 8) return false; } + if (file != 8) return false; } - #endregion - - #region Side To Move - position.color = sideToMoveSpan.Equals("w", StringComparison.OrdinalIgnoreCase) - ? Colors.White - : Colors.Black; - #endregion - - #region Castling - if (castlingSpan.Contains("K", StringComparison.Ordinal)) + if (fields[1] is not ("w" or "b")) return false; + candidate.color = fields[1] == "w" ? Colors.White : Colors.Black; + if (fields[2] != "-") { - position.castlings |= Castlings.WK; + int lastIndex = -1; + foreach (char c in fields[2]) + { + int index = "KQkq".IndexOf(c); + if (index <= lastIndex) return false; + candidate.castlings |= 1 << index; + lastIndex = index; + } } - - if (castlingSpan.Contains("Q", StringComparison.Ordinal)) + if (fields[3] != "-") { - position.castlings |= Castlings.WQ; + if (!AlgebraicNotation.TryParseSquare(fields[3], out candidate.enpassant) + || fields[3][1] is not ('3' or '6')) return false; } + if (!TryCounter(fields[4], out candidate.halfMoveClock) + || !TryCounter(fields[5], out candidate.fullMoveCount) || candidate.fullMoveCount == 0) return false; + position = candidate; + return true; + } - if (castlingSpan.Contains("k", StringComparison.Ordinal)) - { - position.castlings |= Castlings.BK; - } + private static bool TryCounter(string text, out BigInteger value) + { + value = 0; + return text.All(c => c is >= '0' and <= '9') + && BigInteger.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } - if (castlingSpan.Contains("q", StringComparison.Ordinal)) - { - position.castlings |= Castlings.BQ; - } - #endregion + public static string Format(Position position) + { + if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); + return FormatUnchecked(position); + } - #region EnPassant & HalfMoveClock & FullMoveNumber - if (AlgebraicNotation.TryParseSquare(enPassantSpan, out int enPassantSquare)) - { - position.enpassant = enPassantSquare; - } - - if (int.TryParse(halfMoveClockSpan, out var halfMoveClock)) - { - position.halfMoveClock = halfMoveClock; - } - - if (int.TryParse(fullMoveNumberSpan, out var fullMoveNumber)) + internal static string FormatUnchecked(Position position) + { + var result = new StringBuilder(); + for (int rank = 7; rank >= 0; rank--) { - position.fullMoveCount = fullMoveNumber; + int empty = 0; + for (int file = 0; file < 8; file++) + { + var piece = position.GetPieceAt(rank * 8 + file, Colors.White); + if (!Piece.IsValid(piece)) piece = position.GetPieceAt(rank * 8 + file, Colors.Black); + if (!Piece.IsValid(piece)) { empty++; continue; } + if (empty != 0) { result.Append(empty); empty = 0; } + char c = "pnbrqk"[piece.Value]; + result.Append(piece.Color == Colors.White ? char.ToUpperInvariant(c) : c); + } + if (empty != 0) result.Append(empty); + if (rank != 0) result.Append('/'); } - #endregion - - return true; + result.Append(position.color == Colors.White ? " w " : " b "); + if (position.castlings == Castlings.None) result.Append('-'); + else for (int i = 0; i < 4; i++) if ((position.castlings & (1 << i)) != 0) result.Append("KQkq"[i]); + result.Append(' ').Append(position.enpassant == Squares.Empty ? "-" : SquareOps.ToAbbreviature(position.enpassant)); + result.Append(' ').Append(position.halfMoveClock.ToString(CultureInfo.InvariantCulture)); + result.Append(' ').Append(position.fullMoveCount.ToString(CultureInfo.InvariantCulture)); + return result.ToString(); } - - [GeneratedRegex( - "^(?((?[pnbrqkPNBRQK1-8]{1,8})\\/?){8})\\s+" + - "(?b|w)\\s+" + - "(?-|K?Q?k?q?)\\s+" + - "(?-|[a-h][36])\\s+" + - "(?\\d+)\\s+" + - "(?\\d+)\\s*$", RegexOptions.Compiled | RegexOptions.ExplicitCapture)] - private static partial Regex FenRegex(); } From 41635507d31be19cd7ca915b4163033bb6741f88 Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 10 Sep 2026 02:49:32 +0300 Subject: [PATCH 2/2] docs: describe game API contracts and verified rule boundaries --- README.md | 65 +++++----- docs/game-rules-api.md | 222 ++++++++++++++++++++++++++++++++++ docs/game-rules-validation.md | 35 ++++++ docs/known-issues.md | 74 ++++++------ 4 files changed, 323 insertions(+), 73 deletions(-) create mode 100644 docs/game-rules-api.md create mode 100644 docs/game-rules-validation.md diff --git a/README.md b/README.md index d4b7ed7..f7bd507 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ CLI configuration instead. See [the .NET 10 migration record](docs/dotnet-10-migration.md) for package compatibility sources, baseline results and validation limits. -Deferred promotion, completion and history scenarios are in +Implemented game rules and remaining support boundaries are in [docs/known-issues.md](docs/known-issues.md). ### To Do: @@ -95,7 +95,7 @@ Deferred promotion, completion and history scenarios are in - [ ] PGN (optional). - [ ] Hashtables for Perft. - [ ] UCI. -- [ ] Play game functional. +- [X] Standard game API, history, undo and draw rules. ### Historical perft benchmarks (.NET 8) @@ -129,45 +129,44 @@ _This is average result._ _Sometimes benchmarks could be a bit faster or a bit slower._ _(`~3.443 s` or `~3.613 s`)_ -### Example of usage +### Game API -#### Create chess game +See [the complete API guide](docs/game-rules-api.md) for contracts, FEN validation, +FIDE draw rules, compatibility changes and exact dead-position detection limits. -Create start position board. - -`ChessGame chessGame = new();` - -Or +```csharp +using ChessRealms.ChessEngine; -``` -string fen = ""; -_ = ChessGame.TryCreateFromFen(fen, out ChessGame chessGame); +var game = new ChessGame(); +var moves = game.GetLegalMoves(); // read-only snapshot, initially 20 +var copy = game.Clone(); // independent history and repetitions +var result = copy.MakeMove(AlgebraicMove.Parse("e2e4")); +Console.WriteLine(copy.ToFen()); // ... b KQkq e3 0 1 +Console.WriteLine(copy.Outcome); // result, winner, reason +copy.UndoMove(); +Console.WriteLine(copy.Position == game.Position); // True ``` -#### Make move +Promotion is explicit: use `a7a8q`, `a7a8r`, `a7a8b`, or `a7a8n`. +A required promotion without a suffix is rejected without changing the game. -``` -string moveInput = "a2a4"; -AlgebraicMove move = AlgebraicMove.Parse(inputMove); -MoveResult moveResult = chessGame.MakeMove(in move); -Console.WriteLine(moveResult); -// >> Move -// There also could be 'Check', 'Capture', 'Checkmate', 'Stalemate'. -// MoveResult is enums with flags. -``` - -#### Get Board +```csharp +if (ChessGame.TryCreateFromFen("7k/P7/8/8/8/8/8/7K w - - 0 1", out var promotion)) + promotion.MakeMove(AlgebraicMove.Parse("a7a8n")); +if (game.AvailableDrawClaims.HasFlag(DrawClaim.ThreefoldRepetition)) + game.ClaimDraw(DrawClaim.ThreefoldRepetition); ``` -Span pieces = stackalloc ChessPiece[64]; -chessGame.GetBoardToSpan(pieces); -Console.WriteLine("{0}, {1}", - pieces[0].PieceColor, - pieces[0].PieceValue); +Threefold repetition and 50 moves require a claim; fivefold and 75 moves finish +automatically, with checkmate taking priority. Intended-move claims are supported. +The side to move always switches after a successful move, including checkmate; +display the winner from `Outcome.Winner`. -// >> White, Rook -``` +`ChessGame` is now a class: use `Clone()` for analysis copies. Failed FEN creation +returns null; FEN parsing is strict. Counters use built-in `BigInteger` and preserve +large decimal values exactly. FEN import starts fresh repetition history. +`GetBoardToSpan` still fills a 64-element board (a1 = 0; empty = `ChessPiece.Empty`). -Empty squares are equal to `ChessPiece.Empty`. -Or just check it with `piece.IsEmpty()`. +Console commands: `moves`, `fen`, `undo`, `claim3 [move]`, `claim50 [move]`, +`quit`, or a coordinate move. Undo is available after game completion. diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md new file mode 100644 index 0000000..2c0b661 --- /dev/null +++ b/docs/game-rules-api.md @@ -0,0 +1,222 @@ +# Standard game API + +The engine implements standard chess board rules. It has no chess-engine runtime +dependency. Bitboards and magic attack tables remain the move-generation core. +A game owns its history; positions are copied values with inline bitboard arrays +and immutable `BigInteger` counters. No native memory copy is used for positions. + +## Moves and legal-move snapshots + +`AlgebraicMove` is an immutable value with `Source`, `Target`, and `Promotion`. +`Src` and `Trg` remain coordinate aliases. Squares are numbered a1 = 0 through +h8 = 63. Equality includes promotion. + +`Parse` / `TryParse` accept exactly four lowercase coordinate characters, or five +with a lowercase `q`, `r`, `b`, or `n` suffix. They reject extra characters, +whitespace, uppercase, out-of-range squares and identical source/target. +`Parse` throws `FormatException`; `TryParse` returns false and `AlgebraicMove.Empty`. +Parsing checks representation; the game checks whether that move is legal. + +**Promotion requires a suffix.** `a7a8` parses as an ordinary coordinate move but +is rejected when promotion is required. There is no implicit queen. A suffix on +an ordinary move, e.g. `e2e4q`, is also rejected by the game. + +```csharp +using ChessRealms.ChessEngine; + +var game = new ChessGame(); +var legalMoves = game.GetLegalMoves(); // read-only snapshot, initially 20 +var branch = game.Clone(); // independent board, history and repetition counts +MoveResult result = branch.MakeMove(AlgebraicMove.Parse("e2e4")); +bool accepted = result != MoveResult.None; +string fen = branch.ToFen(); // ... b KQkq e3 0 1 +branch.UndoMove(); +bool restored = branch.Position == game.Position; // all bitboards and FEN fields +``` + +`GetLegalMoves()` returns every playable move for the current side, including all +four quiet/capture promotion variants. It never mutates the game. The ordering is +unspecified. It is empty after completion, including automatic and claimed draws. +`HasMoves()` follows the same contract. + +`MakeMove` selects exactly one legal variant, computes the successor on a copy, +then commits it and one history entry. Invalid moves and moves after completion +return `MoveResult.None` without changing the board, counters, history, repetition +counts or outcome. Invalid manually constructed squares/promotion enum values are +also rejected. A legal move always switches side, even if it checkmates. + +## State and completion + +- `CurrentColor` / `EnemyColor`: the side to move and its opponent. +- `IsInCheck`: check on the current king, also available for a finished position. +- `State`: `Active`, `Check`, or `Finished`; `IsFinished` is the completion flag. +- `Outcome.Result`: `Ongoing`, `WhiteWin`, `BlackWin`, or `Draw`. +- `Outcome.Winner`: White/Black for checkmate; `PieceColor.None` otherwise. +- `Outcome.Reason`: `None`, `Checkmate`, `Stalemate`, `DeadPosition`, + `ThreefoldRepetition`, `FiftyMoveRule`, `FivefoldRepetition`, or + `SeventyFiveMoveRule`. + +`MoveResult` is a description of a successful move, with flags for `Move`, +`Capture`, `Check`, `Checkmate`, and `Stalemate`. Checkmate includes `Check`. +Use `Outcome` for the game result; move flags do not encode every draw reason. +Loaded FENs are classified immediately, including mate, stalemate, supported dead +positions and a halfmove clock of at least 150. + +The rules follow [FIDE Laws of Chess, effective 1 January 2023](https://handbook.fide.com/chapter/e012023), +articles 3, 5.1.1, 5.2 and 9.2–9.6. Checkmate is checked before automatic draws; +a mating 150th halfmove wins (9.6.2). If several draw conditions coincide, +classification prefers stalemate, then supported dead position, then fivefold +repetition, then the 75-move rule. + +## Claims and repetitions + +`AvailableDrawClaims` is a flags value: `None`, `ThreefoldRepetition`, +`FiftyMoveRule`, or both. Three occurrences or 100 halfmoves give the current +player the right to claim; play continues until a claim or another termination. +Five occurrences or 150 halfmoves end the game automatically. + +```csharp +if (game.AvailableDrawClaims.HasFlag(DrawClaim.ThreefoldRepetition)) + game.ClaimDraw(DrawClaim.ThreefoldRepetition); + +// FIDE also permits declaring an intended move before playing it. +var intended = AlgebraicMove.Parse("g1f3"); +DrawClaim afterIntendedMove = game.GetAvailableDrawClaims(intended); +if (afterIntendedMove.HasFlag(DrawClaim.FiftyMoveRule)) + game.ClaimDraw(DrawClaim.FiftyMoveRule, intended); +``` + +Choose exactly one reason when claiming. Invalid/unavailable claims return false +without mutation. Intended-move queries simulate a legal move without playing it. +A successful intended-move claim ends the game **on the current board**, with no +new move/history entry. This models the claim before execution, rather than +playing a move and retroactively claiming for the previous player. If that move +is actually played instead, the normal checkmate/automatic-draw priority applies. + +Repetition compares piece placement, side, castling rights, and en passant only +when at least one **legal** en passant capture exists. A pinned pawn or an en passant +capture that exposes the king does not distinguish the position. Counters do not +participate. Counts are exact string keys, not probabilistic hashes. A loaded FEN +starts at one occurrence with empty history; no prior occurrences are inferred. + +## History, undo and ownership + +`History` is a read-only snapshot of successful `MoveHistoryEntry` values: +move, FEN before/after and move flags. Existing snapshots do not change later. +`Position` is a value snapshot with structural equality over all 12 piece bitboards, +all 3 occupancy bitboards, side, castling, en passant and both counters. + +`UndoMove()` returns false when there is no successful move to undo. Otherwise +it restores the complete previous position, removes the latest occurrence/history +entry and restores its outcome. It also clears any claim made after the move +being undone. Claims are not moves: a claim immediately after loading a FEN cannot +itself be undone by `UndoMove()`. Retain a `Clone()` to branch before claiming. + +`ChessGame` is a sealed mutable class; ordinary C# assignment aliases the same +game. **Use `Clone()` for an independent game.** Clone copies all mutable collections +and the exact outcome, including a claimed draw. Games are not thread-safe; each +concurrent analysis should own a clone. FEN is a position interchange format, +not a history/outcome serialization format. + +## FEN import and export + +```csharp +using ChessRealms.ChessEngine.Parsing; + +if (ChessGame.TryCreateFromFen("7k/P7/8/8/8/8/8/7K w - - 0 1", out var promotionGame)) +{ + promotionGame.MakeMove(AlgebraicMove.Parse("a7a8n")); + Console.WriteLine(promotionGame.ToFen()); +} +if (FenStrings.TryParse(FenStrings.StartPosition, out var position)) + Console.WriteLine(FenStrings.Format(position)); +``` + +The strict grammar uses exactly six fields separated by single ASCII spaces, +without leading/trailing whitespace: + +- Eight slash-separated ranks, each expanding to exactly eight squares. + Only `pnbrqkPNBRQK` and digits 1–8; adjacent empty-run digits are rejected. +- Side `w` or `b`. +- `-` or a nonempty subsequence of `KQkq` in that order, without duplicates. +- `-` or an en passant square on rank 3/6. +- ASCII decimal integers: halfmove **>= 0**, fullmove **>= 1**. No sign, + exponent, fraction, separators or Unicode digits. Leading zeros are accepted + and removed on export. Counters use .NET `BigInteger`, with no fixed 32/64-bit + numeric ceiling: large integers are preserved and incremented exactly rather + than silently replaced with defaults on overflow. Practical input size is + limited by available memory, as for any string. + +Semantic validation requires: + +- Exactly one king per color, nonadjacent kings, and the nonmoving king not in + check. The current king may be checked or checkmated. +- Disjoint piece bitboards, consistent occupancy, at most 16 pieces and 8 pawns + per color, no pawns on the first/eighth ranks. Extra queens/rooks/knights and + extra bishops on either square color must fit the number of missing pawns. +- Every castling right requires its king on e1/e8 and its same-color rook on the + corresponding corner. Blocked paths or attacked transit squares do not make + the FEN invalid; they make castling unavailable. +- En passant rank must match the side to move, target and pawn origin must be + empty, the opposing pawn must occupy the double-push destination, and halfmove + must be zero. **An adjacent capturing pawn is not required.** Export records + the target after every double push, including an uncapturable target. + +No proof of historical reachability is attempted. Validation does not reconstruct +prior moves, captured material or check provenance, nor infer move history from +the counters. This intentionally permits locally consistent analysis positions. + +Invalid input returns false and no partially populated position; +`TryCreateFromFen` returns **null**, not a fallback starting game. +`new ChessGame(position)` and `FenStrings.Format(position)` reject invalid +position values with `ArgumentException`. Public `Position.GetPieceAt` checks +square/color ranges; `Position.IsKingChecked()` rejects invalid positions. + +## Supported dead positions and explicit limits + +Automatic dead-position detection is deliberately sound and incomplete: + +- King versus king. +- King and one bishop/knight versus king, either color. +- Kings with bishops only, when **all bishops occupy the same square color**, + including promoted bishops. + +It does not declare king and two knights versus king dead, nor opposing knights, +opposite-color bishops, or other material just because mate cannot be forced. +These cases can allow mate through cooperative legal play. General dead-position +proof, including blocked pawn structures or fortresses, is not implemented. +Such positions continue until another supported ending condition occurs. + +This is a board-rules API, not a tournament arbiter. It does not implement +resignation, draw offers/agreement, clock/flag-fall, touch-move, scoresheets, +penalties or claim adjudication procedures. AI, UCI, SAN/PGN, Chess960, UI, +networking and search optimization are outside this stage. + +## Safety and compatibility + +All move destinations are written through bounds-checked `Span`. +The full generator reserves **434** entries: at most 16 pieces per side, at most +27 destinations per piece (a pawn has at most 12 promotion variants), plus two +castles. This is a conservative bound for every accepted position, independent +of historical reachability. Short internal spans throw before an out-of-range +write. Internal synthetic geometric fixtures do not bypass public validation. + +Breaking changes from the old API: + +- `ChessGame` changed from struct to class; use `Clone()`, not assignment, to copy. +- `AlgebraicMove` now includes promotion and rejects formerly ignored suffixes. +- FEN parsing is strict; invalid `TryCreateFromFen` results are null. +- Terminal side-to-move is the side that would move next; use `Outcome.Winner` + to display the winner. +- `FinishReason` was replaced with precise supported reasons; old unused + `Draw`, `Mate`, `VoteForDraw`, and `Resign` members were removed. +- `HasMoves(PieceColor)` was removed: the game exposes its actual side's moves. +- Raw `Position` mutation/copy/attack helpers are internal; obtain valid public + positions through FEN or `CreateDefault()`. `Position` remains a copied value, + but is no longer unmanaged/blittable; native memory copying is unsupported. +- Counters exposed by the game are `BigInteger`; convert explicitly if a consumer + requires a fixed-width integer and handle its numeric limits there. + +The Console supports coordinate moves, `moves`, `fen`, `undo`, +`claim3 [move]`, `claim50 [move]`, and `quit`. It displays the explicit result, +winner and finish reason and allows undo after completion. diff --git a/docs/game-rules-validation.md b/docs/game-rules-validation.md new file mode 100644 index 0000000..ead99a4 --- /dev/null +++ b/docs/game-rules-validation.md @@ -0,0 +1,35 @@ +# Game-rules validation — 2026-09-10 + +Base: `origin/main` at `49fc3a5` (`.NET 10` migration), containing `d8eb1e3` +(restored tests and CI). Work branch: `feature/complete-game-rules`. +No applicable AGENTS.md was present in the repository or ancestor directories. +The working tree was clean before this work. + +Local environment: Windows, .NET SDK **10.0.401**, Release configuration. + +| Check | Result | +| --- | --- | +| `dotnet restore src/ChessRealms.ChessEngine.sln --locked-mode` | Passed; no lock-file changes | +| `dotnet build src/ChessRealms.ChessEngine.sln -c Release --no-restore` | Passed; all six projects, 0 warnings, 0 errors | +| `dotnet test src/ChessRealms.ChessEngine.sln -c Release --no-build --filter "TestCategory!=Deep"` | 1010 passed, 0 failed, 0 skipped | +| `dotnet test src/ChessRealms.ChessEngine.sln -c Release --no-build --filter "TestCategory=Deep"` | 16 passed, 0 failed, 0 skipped; 29 seconds | +| Console scripted smoke | Passed: legal moves, invalid draw claim, Fool's Mate, explicit Black winner, terminal rejection, undo, exact FEN | +| `git diff --check` | Passed | + +The 851 pre-existing ordinary tests still run; 159 new cases cover the public API, +FEN validation and buffer safety. The original 16 deep cases and their reference +counts were not changed. Initial-position depth 6 remains **119,060,324** nodes. + +Three seeded playouts run up to 160 plies each. At every position they apply every +offered legal move to an independent clone, verify king safety, disjoint piece +bitboards and matching occupancy, round-trip FEN, and undo to structural Position +equality and equal game/history/repetition state. + +The restored CI configuration remains unchanged and includes the new ordinary +tests automatically. This local Windows run does not claim a new Linux or GitHub +Actions result; no push or merge was performed. + +The branch is configured to use origin and +`refs/heads/feature/complete-game-rules` as its upstream, with pushRemote origin. +The remote feature branch does not exist until the user pushes it; Git may show +the preconfigured upstream as `gone` in the meantime. It does not track main. diff --git a/docs/known-issues.md b/docs/known-issues.md index 8f866f9..befaa43 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -1,40 +1,34 @@ -# Deferred game API issues - -These scenarios describe missing or incorrect behavior for a later game-state/API stage. -They are not assertions that the current behavior is correct. Perft exercises the core -move generator and does not validate game history or the public promotion API. - -## Promotion selection and repeated application - -From `7k/P7/8/8/8/8/8/7K w - - 0 1`, request `a7a8q` (and separately -`a7a8n`, `a7a8r`, `a7a8b`). Each should apply exactly one move and leave only -the selected promoted piece on a8. `AlgebraicMove` currently stores only source -and target; parsing ignores the suffix. `ChessGame.MakeMove` matches every -generated promotion with those coordinates and continues applying them without -stopping after the first match. Resolve promotion representation and selection -together; do not bless an arbitrary default or the resulting overlapping pieces. - -## Terminal positions and moves after completion - -Create from checkmate FEN `7k/6Q1/6K1/8/8/8/8/8 b - - 0 1`, or stalemate FEN -`7k/5Q2/6K1/8/8/8/8/8 b - - 0 1`. `IsFinished` is not initialized from the -position. A future completion model should classify both on creation. - -From the initial position play `f2f3 e7e5 g2g4 d8h4` (Fool's Mate), then request -`a7a6`. The mating move sets `IsFinished`, but leaves the current side as Black; -`MakeMove` has no finished-state guard, allowing another Black move. Define the -terminal side-to-move contract and reject subsequent moves without mutation. - -## History, repetition and move clocks - -From the initial position repeat `g1f3 g8f6 f3g1 f6g8` twice. This produces the -third occurrence of the initial position, but there is no position history or -repetition/claim API. A future implementation must also compare side to move, -castling rights and relevant en-passant rights, not just piece placement. - -From `4k3/8/8/8/8/8/8/R3K3 w - - 99 50`, play `a1a2`, then `e8e7`. -The halfmove clock should advance to 100 then 101, and the fullmove number to 51 -after Black's move. `MoveDriver` currently updates neither clock. Pawn moves and -captures must reset the halfmove clock. `Position.CreateDefault` also initializes -the clocks to halfmove 1 / fullmove 0 rather than 0 / 1. Implement clock maintenance -with the future history and draw-claim policy; perft deliberately ignores clocks. +# Game API status + +The deferred game-rules stage is implemented on `feature/complete-game-rules`. +See [the API contracts and support boundaries](game-rules-api.md). + +| Previous defect | Resolution and regression coverage | +| --- | --- | +| Promotion suffix lost; several variants applied to one board | Promotion participates in move equality; exact matching applies one variant atomically. Both colors, four pieces, quiet/capture, missing suffix and undo are covered. | +| Default castling rights/clocks incorrect | Starting FEN is exactly the standard position with KQkq, halfmove 0, fullmove 1. All castlings and rights loss on king/rook movement or rook capture are covered. | +| Loaded mate/stalemate not classified; moves accepted after completion | Initialization evaluates endings; every successful move switches side; terminal moves are rejected without mutation. Both colors and undo from mate/draw are covered. | +| Permissive FEN and numeric overflow | Strict structural and local semantic validation precedes generation. Invalid ranks, kings, material, castling and en passant are rejected. BigInteger counters preserve and advance values beyond Int32/Int64 exactly; malformed numbers are rejected. | +| Clocks/history/repetition absent | Counters, successful-move snapshots, independent Clone and exact UndoMove are implemented. Repetition includes only legally available en passant. Claims at 3/50 and automatic endings at 5/75 are distinct, including intended-move claims and mate priority. | +| Unchecked move-buffer writes and unproven capacity 218 | Every move write uses Span bounds checks. The full generator uses the conservative bound 434 for accepted positions; short-buffer sentinel tests cover safety. Position bitboards remain inline values and magic attacks are retained. | + +Regression suites: `CompleteGameRulesTests`, `StrictFenTests`, the existing core +tests, and unchanged fast/deep `PerftTests`. Tests also execute deterministic legal +playouts, apply every offered move to independent copies, check bitboard invariants, +and undo to equal full positions and repetition history. + +## Remaining rule boundary + +Dead-position recognition covers K–K, K+B–K, K+N–K and bishops-only material when +all bishops occupy one square color. It does not prove arbitrary dead positions +or blocked fortresses. Unrecognized dead positions can remain active until +another supported ending condition; lack of a forced mate is never itself used +to award a draw. + +FEN validation establishes local invariants, not full historical reachability. +A FEN does not preserve pre-import repetitions or claimed outcomes. Clone preserves +both within a running process. + +Tournament procedures, draw agreement, resignation, clocks, SAN/PGN, Chess960, +AI/UCI, networking and UI remain outside this stage. These are support boundaries, +not disabled tests or unimplemented claims of this API.