From 4eedd3b25c363c3b13be7f742276c49dd845c9e1 Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 24 Sep 2026 02:42:14 +0300 Subject: [PATCH 1/4] refactor(api): prepare engine for first NuGet release --- LICENSE.txt | 2 +- README.md | 24 +++++-- docs/game-rules-api.md | 25 ++++--- src/ChessRealms.Engine.Console/Program.cs | 24 +++---- .../ChessRealms.Engine.Perft.csproj | 5 ++ src/ChessRealms.Engine.Perft/PerftDriver.cs | 6 +- .../ChessGameTests.cs | 20 +++--- .../CompleteGameRulesTests.cs | 70 ++++++++++--------- .../Parsing/AlgebraicNotationTests.cs | 18 ++--- .../Parsing/StrictFenTests.cs | 4 +- .../PublicApiSurfaceTests.cs | 48 +++++++++++++ src/ChessRealms.Engine/AlgebraicMove.cs | 24 ------- src/ChessRealms.Engine/ChessGame.cs | 57 ++++++++++----- src/ChessRealms.Engine/ChessPiece.cs | 28 ++++++-- .../ChessRealms.Engine.csproj | 23 ++++++ .../Common/PieceCharsets.cs | 2 +- src/ChessRealms.Engine/Common/UnsafeArrays.cs | 2 +- src/ChessRealms.Engine/CoordinateMove.cs | 55 +++++++++++++++ .../Core/Constants/DefaultChessConstants.cs | 14 ++-- .../Core/Math/BitboardOps.cs | 2 +- src/ChessRealms.Engine/Core/Math/SquareOps.cs | 2 +- src/ChessRealms.Engine/Core/Types/Piece.cs | 2 +- src/ChessRealms.Engine/Core/Types/Position.cs | 2 +- src/ChessRealms.Engine/GameOutcome.cs | 2 +- .../Parsing/AlgebraicNotation.cs | 16 ++--- src/ChessRealms.Engine/Parsing/FenStrings.cs | 8 +-- src/ChessRealms.Engine/PieceColor.cs | 21 +++--- src/ChessRealms.Engine/PieceValue.cs | 40 ++++++++--- src/ChessRealms.Engine/Square.cs | 39 +++++++++-- 29 files changed, 398 insertions(+), 187 deletions(-) create mode 100644 src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs delete mode 100644 src/ChessRealms.Engine/AlgebraicMove.cs create mode 100644 src/ChessRealms.Engine/CoordinateMove.cs diff --git a/LICENSE.txt b/LICENSE.txt index 8aa2645..7cd2493 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2026 ChessRealms contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fb1cb12..bc985fc 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ search tool. It does not provide an AI/search engine, UCI, SAN/PGN, Chess960 or tournament services such as clocks and draw agreements. Dead-position recognition is -deliberately incomplete; see the [game API guide](docs/game-rules-api.md) for -the exact boundary. +deliberately incomplete; see the +[game API guide](https://github.com/ChessRealms/Engine/blob/main/docs/game-rules-api.md) +for the exact boundary. ## Build and test @@ -21,10 +22,12 @@ root: dotnet restore ChessRealms.Engine.slnx --locked-mode dotnet build ChessRealms.Engine.slnx -c Release --no-restore dotnet test ChessRealms.Engine.slnx -c Release --no-build --filter "TestCategory!=Deep" +dotnet pack src/ChessRealms.Engine/ChessRealms.Engine.csproj -c Release --no-build ``` -The `Deep` test category contains slower perft cases and is opt-in. See -[CONTRIBUTING.md](CONTRIBUTING.md) for contributor checks. +The `Deep` test category contains slower perft cases and is opt-in. See the +[contribution guide](https://github.com/ChessRealms/Engine/blob/main/CONTRIBUTING.md) +for contributor checks. ## Public API @@ -34,16 +37,23 @@ using ChessRealms.Engine; var game = new ChessGame(); var branch = game.Clone(); -var result = branch.MakeMove(AlgebraicMove.Parse("e2e4")); +var result = branch.MakeMove(CoordinateMove.Parse("e2e4")); if (result != MoveResult.None) { Console.WriteLine(branch.ToFen()); + ChessPiece piece = branch.GetPiece(Square.Parse("e4")); branch.UndoMove(); } ``` `ChessGame` is mutable: use `Clone()` for an independent branch. Coordinate promotions require a suffix such as `a7a8q`. The -[game API guide](docs/game-rules-api.md) describes ownership, FEN validation, -draw claims, repetition and supported rule boundaries. +[game API guide](https://github.com/ChessRealms/Engine/blob/main/docs/game-rules-api.md) +describes ownership, FEN validation, draw claims, repetition and supported rule +boundaries. + +The package intentionally exposes only the high-level types in the +`ChessRealms.Engine` namespace. Bitboards, encoded moves, magic tables, raw +positions and repository tools are implementation details and may change +without becoming package contracts. diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 3cf62ed..84d66ca 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -1,14 +1,14 @@ # Game API and rule boundaries `ChessGame` is a mutable, sealed standard-chess game. A new game starts at the -usual position; `TryCreateFromFen` starts from a supplied position. The game -generates legal moves, applies them, records successful moves and classifies -endings. Move generation uses bitboards and magic attack tables; pseudolegal -moves are filtered for king safety. +usual position; `FromFen` and `TryCreateFromFen` start from a supplied position. +The game generates legal moves, applies them, records successful moves and +classifies endings. Move generation uses bitboards and magic attack tables; +pseudolegal moves are filtered for king safety. ## Moves, state and ownership -- `AlgebraicMove.Parse` accepts lowercase coordinate moves such as `e2e4` and +- `CoordinateMove.Parse` accepts lowercase coordinate moves such as `e2e4` and promotion moves ending in `q`, `r`, `b` or `n`. Parsing checks syntax; the game checks legality. Promotion has no default queen: `a7a8` is rejected if a promotion is required. `Parse` throws on malformed input; `TryParse` returns @@ -19,7 +19,8 @@ moves are filtered for king safety. finished game without changing state; successful moves switch the side to move, including a checkmating move. Use `Outcome.Winner`, not the side to move, to identify the winner. -- `Position` is a value snapshot. `History` is a read-only snapshot of successful +- `GetPiece(Square)` reads one square. `CopyBoardTo(Span)` copies all + 64 squares in a1-to-h8 order. `History` is a read-only snapshot of successful moves with the move, FEN before/after and move flags. `UndoMove()` restores the previous position, repetition count and outcome, including after a terminal move or a draw claim made after that move. It returns false if there @@ -32,8 +33,10 @@ moves are filtered for king safety. `MoveResult` describes a successful move (`Move`, `Capture`, `Check`, `Checkmate`, `Stalemate`); `Outcome` describes the game result and finish -reason. Loaded positions are classified immediately. `GetBoardToSpan` requires -64 entries in a1-to-h8 order, with `ChessPiece.Empty` for vacant squares. +reason. Loaded positions are classified immediately. `CopyBoardTo` requires a +destination of at least 64 entries, with `ChessPiece.Empty` for vacant squares. +The raw bitboard position is deliberately not exposed; use FEN, board inspection, +legal moves, history and `Clone()` for consumer scenarios. ## FEN validation @@ -52,9 +55,9 @@ target does not require an adjacent capturing pawn. Export records a target after every double push. Validation does not prove historical reachability. Invalid `TryCreateFromFen` input returns false with a null game; it never -substitutes the starting position. `new ChessGame(position)` and -`FenStrings.Format(position)` throw `ArgumentException` for invalid positions. -A FEN import begins with one occurrence and no move history. +substitutes the starting position. `FromFen` throws `FormatException` for invalid +input. A FEN import begins with one occurrence and no move history. The standard +starting FEN is available as `ChessGame.StartingFen`. ## Draws and repetition diff --git a/src/ChessRealms.Engine.Console/Program.cs b/src/ChessRealms.Engine.Console/Program.cs index e0fa756..ffbd89a 100644 --- a/src/ChessRealms.Engine.Console/Program.cs +++ b/src/ChessRealms.Engine.Console/Program.cs @@ -1,6 +1,4 @@ using ChessRealms.Engine; -using ChessRealms.Engine.Common; -using ChessRealms.Engine.Core.Math; MoveResult lastMoveResult = MoveResult.None; ChessGame chessGame = new(); @@ -30,19 +28,19 @@ { 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) + : command.Length == 2 && CoordinateMove.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) + bool success = CoordinateMove.TryParse(input, out var move) && (lastMoveResult = chessGame.MakeMove(move)) != MoveResult.None; if (!success) Console.WriteLine("Invalid move or game already finished."); } static void PrintBoard(ChessGame chessGame) { Span pieceSpan = stackalloc ChessPiece[64]; - chessGame.GetBoardToSpan(pieceSpan); + chessGame.CopyBoardTo(pieceSpan); Console.WriteLine(" a b c d e f g h"); @@ -52,9 +50,9 @@ static void PrintBoard(ChessGame chessGame) for (int f = 0; f < 8; ++f) { - int square = SquareOps.FromFileRank(f, r); + int square = r * 8 + f; - if (pieceSpan[square].IsEmpty()) + if (pieceSpan[square].IsEmpty) { Console.Write('.'); } @@ -74,12 +72,12 @@ static char PieceToString(ref ChessPiece piece) { char p = piece.Value switch { - PieceValue.Pawn => PieceCharsets.Ascii.Pawn, - PieceValue.Knight => PieceCharsets.Ascii.Knight, - PieceValue.Bishop => PieceCharsets.Ascii.Bishop, - PieceValue.Rook => PieceCharsets.Ascii.Rook, - PieceValue.Queen => PieceCharsets.Ascii.Queen, - PieceValue.King => PieceCharsets.Ascii.King, + PieceValue.Pawn => 'p', + PieceValue.Knight => 'n', + PieceValue.Bishop => 'b', + PieceValue.Rook => 'r', + PieceValue.Queen => 'q', + PieceValue.King => 'k', _ => '\0' }; diff --git a/src/ChessRealms.Engine.Perft/ChessRealms.Engine.Perft.csproj b/src/ChessRealms.Engine.Perft/ChessRealms.Engine.Perft.csproj index f73f6e2..7655e70 100644 --- a/src/ChessRealms.Engine.Perft/ChessRealms.Engine.Perft.csproj +++ b/src/ChessRealms.Engine.Perft/ChessRealms.Engine.Perft.csproj @@ -12,4 +12,9 @@ + + + + + diff --git a/src/ChessRealms.Engine.Perft/PerftDriver.cs b/src/ChessRealms.Engine.Perft/PerftDriver.cs index 511f765..f9484c1 100644 --- a/src/ChessRealms.Engine.Perft/PerftDriver.cs +++ b/src/ChessRealms.Engine.Perft/PerftDriver.cs @@ -5,9 +5,9 @@ namespace Perft { - public static class PerftDriver + internal static class PerftDriver { - public struct PerftResult + internal struct PerftResult { public ulong Nodes; public int Captures; @@ -27,7 +27,7 @@ public override readonly string ToString() } } - public static PerftResult Test(Position pos, int depth, bool upper = true) + internal static PerftResult Test(Position pos, int depth, bool upper = true) { Position tmpPos = new(); Span moves = stackalloc int[MoveGen.MaxMoves]; diff --git a/src/ChessRealms.Engine.Tests/ChessGameTests.cs b/src/ChessRealms.Engine.Tests/ChessGameTests.cs index cef1dca..71c825b 100644 --- a/src/ChessRealms.Engine.Tests/ChessGameTests.cs +++ b/src/ChessRealms.Engine.Tests/ChessGameTests.cs @@ -7,7 +7,7 @@ internal class ChessGameTests private static ChessPiece[] Board(ChessGame game) { var board = new ChessPiece[64]; - game.GetBoardToSpan(board); + game.CopyBoardTo(board); return board; } @@ -24,9 +24,9 @@ public void NewGame_HasCompleteInitialBoardAndWhiteToMove() Assert.Multiple(() => { Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.White)); - Assert.That(game.EnemyColor, Is.EqualTo(PieceColor.Black)); + Assert.That(game.OpponentColor, Is.EqualTo(PieceColor.Black)); Assert.That(game.IsFinished, Is.False); - Assert.That(game.HasMoves(), Is.True); + Assert.That(game.HasLegalMoves, Is.True); for (int file = 0; file < 8; file++) { Assert.That(board[file], Is.EqualTo(new ChessPiece(PieceColor.White, backRank[file]))); @@ -46,7 +46,7 @@ public void CreateFromFen_PreservesBoardAndBlackToMove() Assert.Multiple(() => { Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); - Assert.That(board.Count(piece => !piece.IsEmpty()), Is.EqualTo(3)); + Assert.That(board.Count(piece => !piece.IsEmpty), Is.EqualTo(3)); AssertPiece(board, "e8", PieceColor.Black, PieceValue.King); AssertPiece(board, "e1", PieceColor.White, PieceValue.King); AssertPiece(board, "e2", PieceColor.White, PieceValue.Pawn); @@ -57,13 +57,13 @@ public void CreateFromFen_PreservesBoardAndBlackToMove() public void OrdinaryMoves_UpdateBoardAndAlternateSides() { ChessGame game = new(); - Assert.That(game.MakeMove(AlgebraicMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); + Assert.That(game.MakeMove(CoordinateMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); 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); Assert.That(Board(game), Is.EqualTo(expected)); - Assert.That(game.MakeMove(AlgebraicMove.Parse("e7e5")), Is.EqualTo(MoveResult.Move)); + Assert.That(game.MakeMove(CoordinateMove.Parse("e7e5")), Is.EqualTo(MoveResult.Move)); expected[AlgebraicNotation.ParseSquare("e7")] = ChessPiece.Empty; expected[AlgebraicNotation.ParseSquare("e5")] = new(PieceColor.Black, PieceValue.Pawn); Assert.That(Board(game), Is.EqualTo(expected)); @@ -74,12 +74,12 @@ public void OrdinaryMoves_UpdateBoardAndAlternateSides() 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)); + Assert.That(game.MakeMove(CoordinateMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); + Assert.That(game.MakeMove(CoordinateMove.Parse("d7d5")), Is.EqualTo(MoveResult.Move)); 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(game.MakeMove(CoordinateMove.Parse("e4d5")), Is.EqualTo(MoveResult.Move | MoveResult.Capture)); Assert.That(Board(game), Is.EqualTo(expected)); Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); } @@ -94,7 +94,7 @@ public void IllegalMove_PreservesBoardTurnAndFinishedState(string fen, string mo var before = Board(game!); var color = game!.CurrentColor; var finished = game.IsFinished; - Assert.That(game.MakeMove(AlgebraicMove.Parse(move)), Is.EqualTo(MoveResult.None)); + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.EqualTo(MoveResult.None)); Assert.Multiple(() => { Assert.That(Board(game), Is.EqualTo(before)); diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index 80a5a9f..1f117b8 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -17,7 +17,7 @@ private static ChessGame Game(string fen) 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); + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.Not.EqualTo(MoveResult.None), move); } private static string State(ChessGame game) => string.Join("|", game.ToFen(), game.Outcome, game.State, @@ -27,7 +27,7 @@ private static string State(ChessGame game) => string.Join("|", game.ToFen(), ga private static ChessPiece At(ChessGame game, string square) { var board = new ChessPiece[64]; - game.GetBoardToSpan(board); + game.CopyBoardTo(board); return board[AlgebraicNotation.ParseSquare(square)]; } @@ -45,9 +45,9 @@ private static ChessPiece At(ChessGame game, string square) [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)); + Assert.That(CoordinateMove.TryParse(input, out var move), Is.False); + Assert.That(move, Is.EqualTo(default(CoordinateMove))); + Assert.Throws(() => CoordinateMove.Parse(input)); } [TestCase("e2e4", PieceValue.None)] @@ -57,10 +57,10 @@ public void CoordinateInput_IsStrict(string input) [TestCase("a7a8n", PieceValue.Knight)] public void CoordinateInput_RoundTrips(string input, PieceValue promotion) { - Assert.That(AlgebraicMove.TryParse(input, out var move), Is.True); + Assert.That(CoordinateMove.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))); + Assert.That(move, Is.EqualTo(CoordinateMove.Parse(input))); } [Test] @@ -85,14 +85,15 @@ public void RejectedMovesAndSnapshots_CannotMutateOwnedHistory() 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") }) + foreach (var move in new[] { default(CoordinateMove), CoordinateMove.Parse("e7e5q"), + CoordinateMove.Parse("e2e4") }) Assert.That(game.MakeMove(move), Is.EqualTo(MoveResult.None)); + Assert.Throws(() => new CoordinateMove(new Square(Squares.e7), + new Square(Squares.e5), (PieceValue)99)); var history = (IList)game.History; Assert.Throws(() => history.Clear()); - var legal = (IList)game.GetLegalMoves(); - Assert.Throws(() => legal[0] = AlgebraicMove.Empty); + var legal = (IList)game.GetLegalMoves(); + Assert.Throws(() => legal[0] = default); Assert.That(State(game), Is.EqualTo(before)); } @@ -108,14 +109,14 @@ public void Promotions_ApplyExactlyOneVariant_AndUndo( 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(); + var moves = game.GetLegalMoves().Where(m => m.Source.Index == AlgebraicNotation.ParseSquare(src) + && m.Target.Index == 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(game.MakeMove(CoordinateMove.Parse(src + trg)), Is.EqualTo(MoveResult.None)); Assert.That(State(game), Is.EqualTo(before)); - var selected = AlgebraicMove.Parse(src + trg + suffix); + var selected = CoordinateMove.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)); @@ -141,7 +142,7 @@ public void LoadedTerminalPosition_IsClassifiedAndRejectsMoves(string fen, Finis 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.MakeMove(CoordinateMove.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)); @@ -153,13 +154,13 @@ 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")); + var result = game.MakeMove(CoordinateMove.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(game.MakeMove(CoordinateMove.Parse("a7a6")), Is.EqualTo(MoveResult.None)); + Assert.That(game.MakeMove(CoordinateMove.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)); @@ -213,8 +214,8 @@ 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(game.GetLegalMoves(), Does.Not.Contain(CoordinateMove.Parse(move))); + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.EqualTo(MoveResult.None)); Assert.That(State(game), Is.EqualTo(before)); } @@ -224,7 +225,7 @@ 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(game.MakeMove(CoordinateMove.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); @@ -240,8 +241,8 @@ 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(game.GetLegalMoves(), Does.Not.Contain(CoordinateMove.Parse(move))); + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.EqualTo(MoveResult.None)); Assert.That(State(game), Is.EqualTo(before)); } @@ -254,9 +255,9 @@ public void DoublePush_AlwaysRecordsTarget_AndEnPassantExpires() 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"))); + Assert.That(game.GetLegalMoves(), Does.Contain(CoordinateMove.Parse("e5d6"))); Play(game, "g1f3 g8f6"); - Assert.That(game.GetLegalMoves(), Does.Not.Contain(AlgebraicMove.Parse("e5d6"))); + Assert.That(game.GetLegalMoves(), Does.Not.Contain(CoordinateMove.Parse("e5d6"))); } [Test] @@ -264,7 +265,7 @@ 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(game.GetAvailableDrawClaims(CoordinateMove.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))); @@ -304,11 +305,11 @@ 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(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1b2")), Is.False); + Assert.That(game.ClaimDraw((DrawClaim)3, CoordinateMove.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.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.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)); @@ -321,10 +322,10 @@ public void Repetition_ClaimsAtThree_AutomaticAtFive_CloneAndUndoPreserveCounts( 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)); + Assert.That(game.GetAvailableDrawClaims(CoordinateMove.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.ClaimDraw(DrawClaim.ThreefoldRepetition, CoordinateMove.Parse("f6g8")), Is.True); Assert.That(claiming.ToFen(), Is.EqualTo(claimFen)); Assert.That(game.IsFinished, Is.False); Play(game, "f6g8"); @@ -425,7 +426,8 @@ public void DeterministicPlayouts_AllLegalMovesApplyToClones_AndUndoExactly(int 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); + int previousColor = game.CurrentColor == PieceColor.White ? Colors.White : Colors.Black; + Assert.That(copy.Position.IsKingChecked(previousColor), 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); diff --git a/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs b/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs index 1a84881..1c95566 100644 --- a/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs +++ b/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs @@ -17,11 +17,11 @@ public void ParseSquare_A4() public void ParseMove_A1H8() { string a1h8 = "a1h8"; - var move = AlgebraicNotation.ParseAlgebraicMove(a1h8); + var move = AlgebraicNotation.ParseCoordinateMove(a1h8); Assert.Multiple(() => { - Assert.That((int)move.Src, Is.EqualTo(Squares.a1)); - Assert.That((int)move.Trg, Is.EqualTo(Squares.h8)); + Assert.That(move.Source.Index, Is.EqualTo(Squares.a1)); + Assert.That(move.Target.Index, Is.EqualTo(Squares.h8)); }); } @@ -29,13 +29,13 @@ public void ParseMove_A1H8() public void TryParseMove_A1H8_Succeed() { string a1h8 = "a1h8"; - bool parsed = AlgebraicNotation.TryParseAlgebraicMove(a1h8, out AlgebraicMove move); + bool parsed = AlgebraicNotation.TryParseCoordinateMove(a1h8, out CoordinateMove move); Assert.Multiple(() => { Assert.That(parsed, Is.True); - Assert.That((int)move.Src, Is.EqualTo(Squares.a1)); - Assert.That((int)move.Trg, Is.EqualTo(Squares.h8)); + Assert.That(move.Source.Index, Is.EqualTo(Squares.a1)); + Assert.That(move.Target.Index, Is.EqualTo(Squares.h8)); }); } @@ -43,13 +43,13 @@ public void TryParseMove_A1H8_Succeed() public void TryParseMove_A1J3_Failed() { string a1h8 = "a1j3"; - bool parsed = AlgebraicNotation.TryParseAlgebraicMove(a1h8, out AlgebraicMove move); + bool parsed = AlgebraicNotation.TryParseCoordinateMove(a1h8, out CoordinateMove move); Assert.Multiple(() => { Assert.That(parsed, Is.False); - Assert.That((int)move.Src, Is.EqualTo(Squares.Empty)); - Assert.That((int)move.Trg, Is.EqualTo(Squares.Empty)); + Assert.That(move.Source.Index, Is.EqualTo(Squares.Empty)); + Assert.That(move.Target.Index, Is.EqualTo(Squares.Empty)); }); } } diff --git a/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs b/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs index 3ad740e..00d15b3 100644 --- a/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs +++ b/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs @@ -91,14 +91,14 @@ public void Fen_LeadingZeroCountersAreCanonicalized() } [Test] - public void PublicPositionEntryPoints_RejectMalformedState() + public void InternalPositionEntryPoints_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])); + Assert.Throws(() => new ChessGame().CopyBoardTo(new ChessPiece[63])); var p = Position.CreateDefault(); p.SetPieceAt(Squares.a2, Pieces.Queen, Colors.White); Assert.Throws(() => new ChessGame(p)); diff --git a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs new file mode 100644 index 0000000..e2962fc --- /dev/null +++ b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs @@ -0,0 +1,48 @@ +namespace ChessRealms.Engine.Tests; + +internal class PublicApiSurfaceTests +{ + [Test] + public void HighLevelApi_CoversFenBoardMovesAndSafeDefaults() + { + var game = ChessGame.FromFen(ChessGame.StartingFen); + var e2 = Square.Parse("e2"); + + Assert.Multiple(() => + { + Assert.That(default(Square).IsValid, Is.False); + Assert.That(default(ChessPiece), Is.EqualTo(ChessPiece.Empty)); + Assert.That(game.GetPiece(e2), Is.EqualTo(new ChessPiece(PieceColor.White, PieceValue.Pawn))); + Assert.That(game.GetLegalMoves(), Does.Contain(CoordinateMove.Parse("e2e4"))); + Assert.That(game.Clone().ToFen(), Is.EqualTo(ChessGame.StartingFen)); + }); + } + + [Test] + public void ExportedTypes_AreTheReviewedRootSurface() + { + string[] expected = + [ + "ChessRealms.Engine.ChessGame", + "ChessRealms.Engine.ChessPiece", + "ChessRealms.Engine.CoordinateMove", + "ChessRealms.Engine.DrawClaim", + "ChessRealms.Engine.FinishReason", + "ChessRealms.Engine.GameOutcome", + "ChessRealms.Engine.GameResult", + "ChessRealms.Engine.GameState", + "ChessRealms.Engine.MoveHistoryEntry", + "ChessRealms.Engine.MoveResult", + "ChessRealms.Engine.PieceColor", + "ChessRealms.Engine.PieceValue", + "ChessRealms.Engine.Square" + ]; + + string[] actual = typeof(ChessGame).Assembly.GetExportedTypes() + .Select(type => type.FullName!) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/src/ChessRealms.Engine/AlgebraicMove.cs b/src/ChessRealms.Engine/AlgebraicMove.cs deleted file mode 100644 index b6e0f75..0000000 --- a/src/ChessRealms.Engine/AlgebraicMove.cs +++ /dev/null @@ -1,24 +0,0 @@ -using ChessRealms.Engine.Core.Constants; -using ChessRealms.Engine.Core.Math; -using ChessRealms.Engine.Parsing; - -namespace ChessRealms.Engine; - -/// 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 Square Source => Src; - public Square Target => Trg; - public static readonly AlgebraicMove Empty = new(Squares.Empty, Squares.Empty); - 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) - => 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.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 719e8a6..6e3cbc1 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -10,15 +10,18 @@ namespace ChessRealms.Engine; /// A mutable game with exclusive history ownership. Use Clone for independent analysis. public sealed class ChessGame { + /// The FEN for the standard starting position. + public const string StartingFen = FenStrings.StartPosition; + private Position position; 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 PieceColor CurrentColor => position.color.ToPublicColor(); + public PieceColor OpponentColor => Colors.Mirror(position.color).ToPublicColor(); + internal Position Position => position; public BigInteger HalfmoveClock => position.halfMoveClock; public BigInteger FullmoveNumber => position.fullMoveCount; public GameOutcome Outcome { get; private set; } = GameOutcome.Ongoing; @@ -31,7 +34,7 @@ public sealed class ChessGame public ChessGame() : this(Position.CreateDefault()) { } - public ChessGame(Position position) + internal ChessGame(Position position) { if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); this.position = position; @@ -55,25 +58,39 @@ private ChessGame(ChessGame original) public ChessGame Clone() => new(this); public string ToFen() => FenStrings.FormatUnchecked(position); - public void GetBoardToSpan(Span destination) + /// Copies the board in a1-to-h8 order. + public void CopyBoardTo(Span destination) { if (destination.Length < 64) throw new ArgumentException("Board requires 64 squares.", nameof(destination)); for (int i = 0; i < 64; i++) { 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; + destination[i] = Piece.IsValid(piece) + ? new(piece.Color.ToPublicColor(), piece.Value.ToPublicPiece()) + : ChessPiece.Empty; } } + /// Gets the piece on a square, or if it is vacant. + public ChessPiece GetPiece(Square square) + { + if (!square.IsValid) throw new ArgumentException("A valid square is required.", nameof(square)); + var piece = position.GetPieceAt(square.Index, Colors.White); + if (!Piece.IsValid(piece)) piece = position.GetPieceAt(square.Index, Colors.Black); + return Piece.IsValid(piece) + ? new(piece.Color.ToPublicColor(), piece.Value.ToPublicPiece()) + : ChessPiece.Empty; + } + /// Snapshot of playable moves; empty after completion. Does not mutate the game. - public IReadOnlyList GetLegalMoves() => IsFinished - ? Array.Empty() + public IReadOnlyList GetLegalMoves() => IsFinished + ? Array.Empty() : Array.AsReadOnly(LegalMoves(position).Select(ToPublicMove).ToArray()); - public bool HasMoves() => !IsFinished && LegalMoves(position).Count != 0; + public bool HasLegalMoves => !IsFinished && LegalMoves(position).Count != 0; - public MoveResult MakeMove(in AlgebraicMove move) + public MoveResult MakeMove(CoordinateMove move) { if (IsFinished || !TryFindMove(move, out int encoded)) return MoveResult.None; var next = position; @@ -114,7 +131,7 @@ public bool UndoMove() } /// Claims available after an intended legal move, without executing it. - public DrawClaim GetAvailableDrawClaims(in AlgebraicMove intendedMove) + public DrawClaim GetAvailableDrawClaims(CoordinateMove intendedMove) { if (IsFinished || !TryFindMove(intendedMove, out int encoded)) return DrawClaim.None; var next = position; @@ -125,7 +142,7 @@ public DrawClaim GetAvailableDrawClaims(in AlgebraicMove intendedMove) } /// 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) + public bool ClaimDraw(DrawClaim reason, CoordinateMove? intendedMove = null) { if (reason is not (DrawClaim.ThreefoldRepetition or DrawClaim.FiftyMoveRule)) return false; DrawClaim available = intendedMove is { } move ? GetAvailableDrawClaims(move) : AvailableDrawClaims; @@ -134,10 +151,10 @@ public bool ClaimDraw(DrawClaim reason, AlgebraicMove? intendedMove = null) return true; } - private bool TryFindMove(AlgebraicMove move, out int encoded) + private bool TryFindMove(CoordinateMove move, out int encoded) { encoded = 0; - if (!move.IsValid()) return false; + if (!move.IsValid) return false; foreach (int candidate in LegalMoves(position)) { if (ToPublicMove(candidate) != move) continue; @@ -147,9 +164,8 @@ private bool TryFindMove(AlgebraicMove move, out int encoded) return false; } - private static AlgebraicMove ToPublicMove(int move) => new(BinaryMoveOps.DecodeSrc(move), - BinaryMoveOps.DecodeTrg(move), BinaryMoveOps.DecodePromotion(move) == Promotions.None - ? PieceValue.None : (PieceValue)BinaryMoveOps.DecodePromotion(move)); + private static CoordinateMove ToPublicMove(int move) => new(new Square(BinaryMoveOps.DecodeSrc(move)), + new Square(BinaryMoveOps.DecodeTrg(move)), BinaryMoveOps.DecodePromotion(move).ToPublicPromotion()); private static List LegalMoves(Position p) { @@ -182,7 +198,7 @@ private static GameOutcome Evaluate(Position p, int legalCount, int repetitions) if (legalCount == 0) { if (!p.IsKingChecked()) return Draw(FinishReason.Stalemate); - PieceColor winner = (PieceColor)Colors.Mirror(p.color); + PieceColor winner = Colors.Mirror(p.color).ToPublicColor(); return new(winner == PieceColor.White ? GameResult.WhiteWin : GameResult.BlackWin, winner, FinishReason.Checkmate); } if (IsBasicDeadPosition(p)) return Draw(FinishReason.DeadPosition); @@ -209,4 +225,9 @@ public static bool TryCreateFromFen(string? fen, [System.Diagnostics.CodeAnalysi chessGame = FenStrings.TryParse(fen, out var position) ? new ChessGame(position) : null; return chessGame is not null; } + + /// Creates a game from a valid six-field Forsyth-Edwards Notation string. + /// The FEN does not describe a valid standard-chess position. + public static ChessGame FromFen(string fen) + => TryCreateFromFen(fen, out var game) ? game : throw new FormatException("Invalid standard-chess FEN."); } diff --git a/src/ChessRealms.Engine/ChessPiece.cs b/src/ChessRealms.Engine/ChessPiece.cs index fa33b07..cc2ba7e 100644 --- a/src/ChessRealms.Engine/ChessPiece.cs +++ b/src/ChessRealms.Engine/ChessPiece.cs @@ -1,11 +1,29 @@ namespace ChessRealms.Engine; -public readonly struct ChessPiece(PieceColor color, PieceValue piece) +/// A piece on a board square. The default value represents an empty square. +public readonly record struct ChessPiece { - public readonly PieceColor Color = color; - public readonly PieceValue Value = piece; + /// Creates a non-empty chess piece. + public ChessPiece(PieceColor color, PieceValue value) + { + if (color is not (PieceColor.White or PieceColor.Black)) + throw new ArgumentException("A piece must be white or black.", nameof(color)); + if (value is < PieceValue.Pawn or > PieceValue.King) + throw new ArgumentException("A valid piece value is required.", nameof(value)); - public readonly bool IsEmpty() => Color == PieceColor.None && Value == PieceValue.None; + Color = color; + Value = value; + } - public static readonly ChessPiece Empty = new(PieceColor.None, PieceValue.None); + /// The piece's side, or when empty. + public PieceColor Color { get; } + + /// The piece kind, or when empty. + public PieceValue Value { get; } + + /// Whether the square contains no piece. + public bool IsEmpty => this == Empty; + + /// An empty board square. + public static ChessPiece Empty => default; } diff --git a/src/ChessRealms.Engine/ChessRealms.Engine.csproj b/src/ChessRealms.Engine/ChessRealms.Engine.csproj index 8b9955e..346522c 100644 --- a/src/ChessRealms.Engine/ChessRealms.Engine.csproj +++ b/src/ChessRealms.Engine/ChessRealms.Engine.csproj @@ -5,6 +5,24 @@ enable enable true + ChessRealms.Engine + 1.0.0 + ChessRealms.Engine + A reusable .NET library for standard chess positions, legal move generation, game state, history, undo, FEN, and standard game-ending rules. + ChessRealms + chess;chess-engine;bitboard;move-generation;fen + MIT + https://github.com/ChessRealms/Engine + https://github.com/ChessRealms/Engine.git + git + README.md + true + $(NoWarn);CS1591 + true + snupkg + true + true + true @@ -13,4 +31,9 @@ + + + + + diff --git a/src/ChessRealms.Engine/Common/PieceCharsets.cs b/src/ChessRealms.Engine/Common/PieceCharsets.cs index 0f4cf0f..affaa82 100644 --- a/src/ChessRealms.Engine/Common/PieceCharsets.cs +++ b/src/ChessRealms.Engine/Common/PieceCharsets.cs @@ -1,6 +1,6 @@ namespace ChessRealms.Engine.Common; -public static class PieceCharsets +internal static class PieceCharsets { public static class Ascii { diff --git a/src/ChessRealms.Engine/Common/UnsafeArrays.cs b/src/ChessRealms.Engine/Common/UnsafeArrays.cs index 1422a1c..40345ac 100644 --- a/src/ChessRealms.Engine/Common/UnsafeArrays.cs +++ b/src/ChessRealms.Engine/Common/UnsafeArrays.cs @@ -1,6 +1,6 @@ namespace ChessRealms.Engine.Common; -public static unsafe class UnsafeArrays +internal static unsafe class UnsafeArrays { public static HashSet ToHashSet(int* ptr, int length) { diff --git a/src/ChessRealms.Engine/CoordinateMove.cs b/src/ChessRealms.Engine/CoordinateMove.cs new file mode 100644 index 0000000..f4c6c91 --- /dev/null +++ b/src/ChessRealms.Engine/CoordinateMove.cs @@ -0,0 +1,55 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Parsing; + +namespace ChessRealms.Engine; + +/// A move written as source and target squares, with an explicit promotion when required. +public readonly record struct CoordinateMove +{ + /// Creates a coordinate move. + /// A square or promotion value is invalid. + public CoordinateMove(Square source, Square target, PieceValue promotion = PieceValue.None) + { + if (!source.IsValid) throw new ArgumentException("A valid source square is required.", nameof(source)); + if (!target.IsValid) throw new ArgumentException("A valid target square is required.", nameof(target)); + if (source == target) throw new ArgumentException("Source and target squares must differ.", nameof(target)); + if (promotion is not (PieceValue.None or PieceValue.Queen or PieceValue.Rook + or PieceValue.Bishop or PieceValue.Knight)) + throw new ArgumentException("Promotion must be a queen, rook, bishop, knight, or none.", nameof(promotion)); + + Source = source; + Target = target; + Promotion = promotion; + } + + /// The square the moving piece leaves. + public Square Source { get; } + + /// The square the moving piece enters. + public Square Target { get; } + + /// The promoted piece, or for a non-promotion move. + public PieceValue Promotion { get; } + + internal bool IsValid => Source.IsValid && Target.IsValid && Source != Target + && Promotion is PieceValue.None or PieceValue.Queen or PieceValue.Rook or PieceValue.Bishop or PieceValue.Knight; + + /// Parses lowercase long algebraic/coordinate notation such as e2e4 or a7a8q. + public static CoordinateMove Parse(ReadOnlySpan text) => AlgebraicNotation.ParseCoordinateMove(text); + + /// Tries to parse lowercase long algebraic/coordinate notation. + public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) + => AlgebraicNotation.TryParseCoordinateMove(text, out move); + + /// + public override string ToString() => !IsValid ? string.Empty : + SquareOps.ToAbbreviature(Source.Index) + SquareOps.ToAbbreviature(Target.Index) + (Promotion switch + { + PieceValue.Queen => "q", + PieceValue.Rook => "r", + PieceValue.Bishop => "b", + PieceValue.Knight => "n", + _ => string.Empty + }); +} diff --git a/src/ChessRealms.Engine/Core/Constants/DefaultChessConstants.cs b/src/ChessRealms.Engine/Core/Constants/DefaultChessConstants.cs index 0bee079..23e9133 100644 --- a/src/ChessRealms.Engine/Core/Constants/DefaultChessConstants.cs +++ b/src/ChessRealms.Engine/Core/Constants/DefaultChessConstants.cs @@ -2,7 +2,7 @@ namespace ChessRealms.Engine.Core.Constants; -public static class Colors +internal static class Colors { public const int Black = 0; public const int White = 1; @@ -21,7 +21,7 @@ public static bool IsValid(int color) } } -public static class Pieces +internal static class Pieces { public const int Pawn = 0; public const int Knight = 1; @@ -38,7 +38,7 @@ public static bool IsValid(int piece) } } -public static class Promotions +internal static class Promotions { public const int None = 0; public const int Knight = 1; @@ -55,7 +55,7 @@ public static bool IsValid(int promotion) } } -public static class Castlings +internal static class Castlings { public const int None = 0; public const int WK = 1; @@ -84,7 +84,7 @@ public static bool IsValidSingle(int castling) } } -public static class Directions +internal static class Directions { public const int North = 8; public const int South = -8; @@ -98,7 +98,7 @@ public static class Directions public const int SouthWest = South + West; } -public static class BitboardIndicies +internal static class BitboardIndicies { public const int BPawn = 0; public const int BKnight = 1; @@ -119,7 +119,7 @@ public static class BitboardIndicies public const int AllBlockers = 2; } -public static class Squares +internal static class Squares { public const int Empty = -1; diff --git a/src/ChessRealms.Engine/Core/Math/BitboardOps.cs b/src/ChessRealms.Engine/Core/Math/BitboardOps.cs index 863717b..12a795a 100644 --- a/src/ChessRealms.Engine/Core/Math/BitboardOps.cs +++ b/src/ChessRealms.Engine/Core/Math/BitboardOps.cs @@ -4,7 +4,7 @@ namespace ChessRealms.Engine.Core.Math; -public static class BitboardOps +internal static class BitboardOps { [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ulong GetBitAt(ulong bitboard, int square) diff --git a/src/ChessRealms.Engine/Core/Math/SquareOps.cs b/src/ChessRealms.Engine/Core/Math/SquareOps.cs index 7e467df..b228676 100644 --- a/src/ChessRealms.Engine/Core/Math/SquareOps.cs +++ b/src/ChessRealms.Engine/Core/Math/SquareOps.cs @@ -4,7 +4,7 @@ namespace ChessRealms.Engine.Core.Math; -public static class SquareOps +internal static class SquareOps { public const int MinFileRank = 0; public const int MaxFileRank = 7; diff --git a/src/ChessRealms.Engine/Core/Types/Piece.cs b/src/ChessRealms.Engine/Core/Types/Piece.cs index da3f90f..b930421 100644 --- a/src/ChessRealms.Engine/Core/Types/Piece.cs +++ b/src/ChessRealms.Engine/Core/Types/Piece.cs @@ -3,7 +3,7 @@ namespace ChessRealms.Engine.Core.Types; -public readonly struct Piece(int piece, int color) +internal readonly struct Piece(int piece, int color) { public readonly int Value = piece; public readonly int Color = color; diff --git a/src/ChessRealms.Engine/Core/Types/Position.cs b/src/ChessRealms.Engine/Core/Types/Position.cs index eb7de8f..f5d168e 100644 --- a/src/ChessRealms.Engine/Core/Types/Position.cs +++ b/src/ChessRealms.Engine/Core/Types/Position.cs @@ -11,7 +11,7 @@ namespace ChessRealms.Engine.Core.Types; /// A value snapshot. Assignment copies bitboards; its BigInteger counters are immutable. -public struct Position : IEquatable +internal struct Position : IEquatable { internal PieceBoards pieceBBs; internal OccupancyBoards blockers; diff --git a/src/ChessRealms.Engine/GameOutcome.cs b/src/ChessRealms.Engine/GameOutcome.cs index 93d6358..e912ef1 100644 --- a/src/ChessRealms.Engine/GameOutcome.cs +++ b/src/ChessRealms.Engine/GameOutcome.cs @@ -11,4 +11,4 @@ public readonly record struct GameOutcome(GameResult Result, PieceColor Winner, public static GameOutcome Ongoing => new(GameResult.Ongoing, PieceColor.None, FinishReason.None); } -public readonly record struct MoveHistoryEntry(AlgebraicMove Move, string FenBefore, string FenAfter, MoveResult Result); +public readonly record struct MoveHistoryEntry(CoordinateMove Move, string FenBefore, string FenAfter, MoveResult Result); diff --git a/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs b/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs index 5672991..067b691 100644 --- a/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs +++ b/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs @@ -3,14 +3,14 @@ namespace ChessRealms.Engine.Parsing; -public static class AlgebraicNotation +internal static class AlgebraicNotation { - public static AlgebraicMove ParseAlgebraicMove(ReadOnlySpan text) - => TryParseAlgebraicMove(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); + internal static CoordinateMove ParseCoordinateMove(ReadOnlySpan text) + => TryParseCoordinateMove(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); - public static bool TryParseAlgebraicMove(ReadOnlySpan text, out AlgebraicMove move) + internal static bool TryParseCoordinateMove(ReadOnlySpan text, out CoordinateMove move) { - move = AlgebraicMove.Empty; + move = default; 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; @@ -23,14 +23,14 @@ public static bool TryParseAlgebraicMove(ReadOnlySpan text, out AlgebraicM }; if (promotion == PieceValue.None) return false; } - move = new(src, trg, promotion); + move = new(new Square(src), new Square(trg), promotion); return true; } - public static int ParseSquare(ReadOnlySpan text) + internal 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 text, out int square) + internal static bool TryParseSquare(ReadOnlySpan text, out int square) { square = Squares.Empty; if (text.Length != 2 || text[0] is < 'a' or > 'h' || text[1] is < '1' or > '8') return false; diff --git a/src/ChessRealms.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs index a57aa53..08b9af6 100644 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ b/src/ChessRealms.Engine/Parsing/FenStrings.cs @@ -7,11 +7,11 @@ namespace ChessRealms.Engine.Parsing; -public static class FenStrings +internal static class FenStrings { - public const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + internal const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; - public static bool TryParse(string? fen, out Position position) + internal static bool TryParse(string? fen, out Position position) { if (TryParseSyntax(fen, out var candidate) && PositionValidation.IsValid(candidate)) { @@ -87,7 +87,7 @@ private static bool TryCounter(string text, out BigInteger value) && BigInteger.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); } - public static string Format(Position position) + internal static string Format(Position position) { if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); return FormatUnchecked(position); diff --git a/src/ChessRealms.Engine/PieceColor.cs b/src/ChessRealms.Engine/PieceColor.cs index ed0b9bf..42fcb27 100644 --- a/src/ChessRealms.Engine/PieceColor.cs +++ b/src/ChessRealms.Engine/PieceColor.cs @@ -1,20 +1,19 @@ -using ChessRealms.Engine.Core.Constants; -using System.Runtime.CompilerServices; - namespace ChessRealms.Engine; +/// A chess side, or no side for values such as an empty square. public enum PieceColor { - Black = Colors.Black, - White = Colors.White, - None = Colors.None + None, + White, + Black } -public static class PieceColorExtensions +internal static class PieceColorConversions { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsBlackOrWhite(this PieceColor color) + internal static PieceColor ToPublicColor(this int color) => color switch { - return color == PieceColor.Black || color == PieceColor.White; - } + Core.Constants.Colors.White => PieceColor.White, + Core.Constants.Colors.Black => PieceColor.Black, + _ => PieceColor.None + }; } diff --git a/src/ChessRealms.Engine/PieceValue.cs b/src/ChessRealms.Engine/PieceValue.cs index 2c78ff8..4686b52 100644 --- a/src/ChessRealms.Engine/PieceValue.cs +++ b/src/ChessRealms.Engine/PieceValue.cs @@ -1,14 +1,36 @@ -using ChessRealms.Engine.Core.Constants; - namespace ChessRealms.Engine; +/// A chess piece kind, or no piece for an empty square. public enum PieceValue { - Pawn = Pieces.Pawn, - Knight = Pieces.Knight, - Bishop = Pieces.Bishop, - Rook = Pieces.Rook, - Queen = Pieces.Queen, - King = Pieces.King, - None = Pieces.None + None, + Pawn, + Knight, + Bishop, + Rook, + Queen, + King +} + +internal static class PieceValueConversions +{ + internal static PieceValue ToPublicPiece(this int piece) => piece switch + { + Core.Constants.Pieces.Pawn => PieceValue.Pawn, + Core.Constants.Pieces.Knight => PieceValue.Knight, + Core.Constants.Pieces.Bishop => PieceValue.Bishop, + Core.Constants.Pieces.Rook => PieceValue.Rook, + Core.Constants.Pieces.Queen => PieceValue.Queen, + Core.Constants.Pieces.King => PieceValue.King, + _ => PieceValue.None + }; + + internal static PieceValue ToPublicPromotion(this int promotion) => promotion switch + { + Core.Constants.Promotions.Knight => PieceValue.Knight, + Core.Constants.Promotions.Bishop => PieceValue.Bishop, + Core.Constants.Promotions.Rook => PieceValue.Rook, + Core.Constants.Promotions.Queen => PieceValue.Queen, + _ => PieceValue.None + }; } diff --git a/src/ChessRealms.Engine/Square.cs b/src/ChessRealms.Engine/Square.cs index 1a1cec7..fbd0055 100644 --- a/src/ChessRealms.Engine/Square.cs +++ b/src/ChessRealms.Engine/Square.cs @@ -1,9 +1,40 @@ +using ChessRealms.Engine.Parsing; + namespace ChessRealms.Engine; -public readonly struct Square(int value) +/// A square on a standard chess board. +public readonly record struct Square { - public readonly int Value = value; + private readonly byte encodedIndex; + + internal Square(int index) + { + if (index is < 0 or >= 64) throw new ArgumentOutOfRangeException(nameof(index)); + encodedIndex = (byte)(index + 1); + } + + /// Whether this value identifies a board square. + public bool IsValid => encodedIndex is >= 1 and <= 64; + + internal int Index => encodedIndex - 1; + + /// Parses a lowercase square name from a1 through h8. + public static Square Parse(ReadOnlySpan text) + => TryParse(text, out var square) ? square : throw new FormatException("Expected a square a1 through h8."); + + /// Tries to parse a lowercase square name from a1 through h8. + public static bool TryParse(ReadOnlySpan text, out Square square) + { + if (AlgebraicNotation.TryParseSquare(text, out int index)) + { + square = new(index); + return true; + } + + square = default; + return false; + } - public static implicit operator Square(int value) => new(value); - public static implicit operator int(Square sqaure) => sqaure.Value; + /// + public override string ToString() => IsValid ? Core.Math.SquareOps.ToAbbreviature(Index) : string.Empty; } From b4348f9ba9c394579641e27e509538ee2bb11d5c Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 24 Sep 2026 02:55:28 +0300 Subject: [PATCH 2/4] refactor(api): colocate coordinate parsing with value types --- .../ChessGameTests.cs | 14 ++--- .../CompleteGameRulesTests.cs | 6 +- .../Parsing/AlgebraicNotationTests.cs | 55 ------------------- .../PublicApiSurfaceTests.cs | 21 +++++++ src/ChessRealms.Engine/CoordinateMove.cs | 28 ++++++++-- .../Parsing/AlgebraicNotation.cs | 40 -------------- src/ChessRealms.Engine/Parsing/FenStrings.cs | 3 +- src/ChessRealms.Engine/Square.cs | 13 ++--- 8 files changed, 61 insertions(+), 119 deletions(-) delete mode 100644 src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs delete mode 100644 src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs diff --git a/src/ChessRealms.Engine.Tests/ChessGameTests.cs b/src/ChessRealms.Engine.Tests/ChessGameTests.cs index 71c825b..ede8429 100644 --- a/src/ChessRealms.Engine.Tests/ChessGameTests.cs +++ b/src/ChessRealms.Engine.Tests/ChessGameTests.cs @@ -12,7 +12,7 @@ private static ChessPiece[] Board(ChessGame game) } private static void AssertPiece(ChessPiece[] board, string square, PieceColor color, PieceValue value) - => Assert.That(board[AlgebraicNotation.ParseSquare(square)], Is.EqualTo(new ChessPiece(color, value))); + => Assert.That(board[Square.Parse(square).Index], Is.EqualTo(new ChessPiece(color, value))); [Test] public void NewGame_HasCompleteInitialBoardAndWhiteToMove() @@ -60,12 +60,12 @@ public void OrdinaryMoves_UpdateBoardAndAlternateSides() Assert.That(game.MakeMove(CoordinateMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); 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); + expected[Square.Parse("e2").Index] = ChessPiece.Empty; + expected[Square.Parse("e4").Index] = new(PieceColor.White, PieceValue.Pawn); Assert.That(Board(game), Is.EqualTo(expected)); Assert.That(game.MakeMove(CoordinateMove.Parse("e7e5")), Is.EqualTo(MoveResult.Move)); - expected[AlgebraicNotation.ParseSquare("e7")] = ChessPiece.Empty; - expected[AlgebraicNotation.ParseSquare("e5")] = new(PieceColor.Black, PieceValue.Pawn); + expected[Square.Parse("e7").Index] = ChessPiece.Empty; + expected[Square.Parse("e5").Index] = new(PieceColor.Black, PieceValue.Pawn); Assert.That(Board(game), Is.EqualTo(expected)); Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.White)); } @@ -77,8 +77,8 @@ public void Capture_RemovesEnemyAndMovesAttacker() Assert.That(game.MakeMove(CoordinateMove.Parse("e2e4")), Is.EqualTo(MoveResult.Move)); Assert.That(game.MakeMove(CoordinateMove.Parse("d7d5")), Is.EqualTo(MoveResult.Move)); var expected = Board(game!); - expected[AlgebraicNotation.ParseSquare("e4")] = ChessPiece.Empty; - expected[AlgebraicNotation.ParseSquare("d5")] = new(PieceColor.White, PieceValue.Pawn); + expected[Square.Parse("e4").Index] = ChessPiece.Empty; + expected[Square.Parse("d5").Index] = new(PieceColor.White, PieceValue.Pawn); Assert.That(game.MakeMove(CoordinateMove.Parse("e4d5")), Is.EqualTo(MoveResult.Move | MoveResult.Capture)); Assert.That(Board(game), Is.EqualTo(expected)); Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index 1f117b8..f9d7842 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -28,7 +28,7 @@ private static ChessPiece At(ChessGame game, string square) { var board = new ChessPiece[64]; game.CopyBoardTo(board); - return board[AlgebraicNotation.ParseSquare(square)]; + return board[Square.Parse(square).Index]; } [TestCase("")] @@ -109,8 +109,8 @@ public void Promotions_ApplyExactlyOneVariant_AndUndo( string src = black ? "a2" : "a7"; string trg = (capture ? "b" : "a") + (black ? "1" : "8"); string before = State(game); - var moves = game.GetLegalMoves().Where(m => m.Source.Index == AlgebraicNotation.ParseSquare(src) - && m.Target.Index == AlgebraicNotation.ParseSquare(trg)).ToArray(); + var moves = game.GetLegalMoves().Where(m => m.Source == Square.Parse(src) + && m.Target == Square.Parse(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 })); diff --git a/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs b/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs deleted file mode 100644 index 1c95566..0000000 --- a/src/ChessRealms.Engine.Tests/Parsing/AlgebraicNotationTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ChessRealms.Engine.Core.Constants; -using ChessRealms.Engine.Parsing; - -namespace ChessRealms.Engine.Tests.Parsing; - -internal class AlgebraicNotationTests -{ - [Test] - public void ParseSquare_A4() - { - string a4 = "a4"; - int parsedSquare = AlgebraicNotation.ParseSquare(a4); - Assert.That(parsedSquare, Is.EqualTo(Squares.a4)); - } - - [Test] - public void ParseMove_A1H8() - { - string a1h8 = "a1h8"; - var move = AlgebraicNotation.ParseCoordinateMove(a1h8); - Assert.Multiple(() => - { - Assert.That(move.Source.Index, Is.EqualTo(Squares.a1)); - Assert.That(move.Target.Index, Is.EqualTo(Squares.h8)); - }); - } - - [Test] - public void TryParseMove_A1H8_Succeed() - { - string a1h8 = "a1h8"; - bool parsed = AlgebraicNotation.TryParseCoordinateMove(a1h8, out CoordinateMove move); - - Assert.Multiple(() => - { - Assert.That(parsed, Is.True); - Assert.That(move.Source.Index, Is.EqualTo(Squares.a1)); - Assert.That(move.Target.Index, Is.EqualTo(Squares.h8)); - }); - } - - [Test] - public void TryParseMove_A1J3_Failed() - { - string a1h8 = "a1j3"; - bool parsed = AlgebraicNotation.TryParseCoordinateMove(a1h8, out CoordinateMove move); - - Assert.Multiple(() => - { - Assert.That(parsed, Is.False); - Assert.That(move.Source.Index, Is.EqualTo(Squares.Empty)); - Assert.That(move.Target.Index, Is.EqualTo(Squares.Empty)); - }); - } -} diff --git a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs index e2962fc..b38c6f6 100644 --- a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs +++ b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs @@ -18,6 +18,27 @@ public void HighLevelApi_CoversFenBoardMovesAndSafeDefaults() }); } + [TestCase("a1")] + [TestCase("h8")] + [TestCase("e4")] + public void Square_ParsesAndRoundTrips(string text) + { + Assert.That(Square.TryParse(text, out var square), Is.True); + Assert.That(square.ToString(), Is.EqualTo(text)); + } + + [TestCase("")] + [TestCase("A1")] + [TestCase("a0")] + [TestCase("i1")] + [TestCase("a1 ")] + public void Square_RejectsInvalidText(string text) + { + Assert.That(Square.TryParse(text, out var square), Is.False); + Assert.That(square, Is.EqualTo(default(Square))); + Assert.Throws(() => Square.Parse(text)); + } + [Test] public void ExportedTypes_AreTheReviewedRootSurface() { diff --git a/src/ChessRealms.Engine/CoordinateMove.cs b/src/ChessRealms.Engine/CoordinateMove.cs index f4c6c91..45063ff 100644 --- a/src/ChessRealms.Engine/CoordinateMove.cs +++ b/src/ChessRealms.Engine/CoordinateMove.cs @@ -1,6 +1,4 @@ -using ChessRealms.Engine.Core.Constants; using ChessRealms.Engine.Core.Math; -using ChessRealms.Engine.Parsing; namespace ChessRealms.Engine; @@ -36,11 +34,33 @@ public CoordinateMove(Square source, Square target, PieceValue promotion = Piece && Promotion is PieceValue.None or PieceValue.Queen or PieceValue.Rook or PieceValue.Bishop or PieceValue.Knight; /// Parses lowercase long algebraic/coordinate notation such as e2e4 or a7a8q. - public static CoordinateMove Parse(ReadOnlySpan text) => AlgebraicNotation.ParseCoordinateMove(text); + public static CoordinateMove Parse(ReadOnlySpan text) + => TryParse(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); /// Tries to parse lowercase long algebraic/coordinate notation. public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) - => AlgebraicNotation.TryParseCoordinateMove(text, out move); + { + move = default; + if (text.Length is not (4 or 5) || !Square.TryParse(text[..2], out var source) + || !Square.TryParse(text.Slice(2, 2), out var target) || source == target) return false; + + PieceValue promotion = PieceValue.None; + if (text.Length == 5) + { + 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 = new(source, target, promotion); + return true; + } /// public override string ToString() => !IsValid ? string.Empty : diff --git a/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs b/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs deleted file mode 100644 index 067b691..0000000 --- a/src/ChessRealms.Engine/Parsing/AlgebraicNotation.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ChessRealms.Engine.Core.Constants; -using ChessRealms.Engine.Core.Math; - -namespace ChessRealms.Engine.Parsing; - -internal static class AlgebraicNotation -{ - internal static CoordinateMove ParseCoordinateMove(ReadOnlySpan text) - => TryParseCoordinateMove(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); - - internal static bool TryParseCoordinateMove(ReadOnlySpan text, out CoordinateMove move) - { - move = default; - 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) - { - 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 = new(new Square(src), new Square(trg), promotion); - return true; - } - - internal static int ParseSquare(ReadOnlySpan text) - => TryParseSquare(text, out int square) ? square : throw new FormatException("Expected a square a1 through h8."); - - internal static bool TryParseSquare(ReadOnlySpan text, out int square) - { - square = Squares.Empty; - 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.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs index 08b9af6..ed58d85 100644 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ b/src/ChessRealms.Engine/Parsing/FenStrings.cs @@ -71,8 +71,9 @@ internal static bool TryParseSyntax(string? fen, out Position position) } if (fields[3] != "-") { - if (!AlgebraicNotation.TryParseSquare(fields[3], out candidate.enpassant) + if (!Square.TryParse(fields[3], out var enPassant) || fields[3][1] is not ('3' or '6')) return false; + candidate.enpassant = enPassant.Index; } if (!TryCounter(fields[4], out candidate.halfMoveClock) || !TryCounter(fields[5], out candidate.fullMoveCount) || candidate.fullMoveCount == 0) return false; diff --git a/src/ChessRealms.Engine/Square.cs b/src/ChessRealms.Engine/Square.cs index fbd0055..3a65d12 100644 --- a/src/ChessRealms.Engine/Square.cs +++ b/src/ChessRealms.Engine/Square.cs @@ -1,5 +1,3 @@ -using ChessRealms.Engine.Parsing; - namespace ChessRealms.Engine; /// A square on a standard chess board. @@ -25,14 +23,11 @@ public static Square Parse(ReadOnlySpan text) /// Tries to parse a lowercase square name from a1 through h8. public static bool TryParse(ReadOnlySpan text, out Square square) { - if (AlgebraicNotation.TryParseSquare(text, out int index)) - { - square = new(index); - return true; - } - square = default; - return false; + if (text.Length != 2 || text[0] is < 'a' or > 'h' || text[1] is < '1' or > '8') return false; + + square = new((text[1] - '1') * 8 + text[0] - 'a'); + return true; } /// From efbfc794d48cb05649dea0a949b6643b5737af1c Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 24 Sep 2026 15:49:36 +0300 Subject: [PATCH 3/4] refactor(api): improve coordinate parsing readability --- src/ChessRealms.Engine/CoordinateMove.cs | 34 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/ChessRealms.Engine/CoordinateMove.cs b/src/ChessRealms.Engine/CoordinateMove.cs index 45063ff..745f3f7 100644 --- a/src/ChessRealms.Engine/CoordinateMove.cs +++ b/src/ChessRealms.Engine/CoordinateMove.cs @@ -35,14 +35,36 @@ public CoordinateMove(Square source, Square target, PieceValue promotion = Piece /// Parses lowercase long algebraic/coordinate notation such as e2e4 or a7a8q. public static CoordinateMove Parse(ReadOnlySpan text) - => TryParse(text, out var move) ? move : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); + { + return TryParse(text, out var move) + ? move + : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); + } /// Tries to parse lowercase long algebraic/coordinate notation. public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) { move = default; - if (text.Length is not (4 or 5) || !Square.TryParse(text[..2], out var source) - || !Square.TryParse(text.Slice(2, 2), out var target) || source == target) return false; + + if (text.Length is not (4 or 5)) + { + return false; + } + + if (!Square.TryParse(text[..2], out var source)) + { + return false; + } + + if (!Square.TryParse(text.Slice(2, 2), out var target)) + { + return false; + } + + if (source == target) + { + return false; + } PieceValue promotion = PieceValue.None; if (text.Length == 5) @@ -55,7 +77,11 @@ public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) 'n' => PieceValue.Knight, _ => PieceValue.None }; - if (promotion == PieceValue.None) return false; + + if (promotion == PieceValue.None) + { + return false; + } } move = new(source, target, promotion); From 401bb9f2243c56029d9bc44f7f899004f9b6f2fd Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 24 Sep 2026 15:52:12 +0300 Subject: [PATCH 4/4] refactor: remove unused common namespace (piece-charset and unsafe-array features) --- .../Common/PieceCharsets.cs | 19 ------------------- src/ChessRealms.Engine/Common/UnsafeArrays.cs | 16 ---------------- 2 files changed, 35 deletions(-) delete mode 100644 src/ChessRealms.Engine/Common/PieceCharsets.cs delete mode 100644 src/ChessRealms.Engine/Common/UnsafeArrays.cs diff --git a/src/ChessRealms.Engine/Common/PieceCharsets.cs b/src/ChessRealms.Engine/Common/PieceCharsets.cs deleted file mode 100644 index affaa82..0000000 --- a/src/ChessRealms.Engine/Common/PieceCharsets.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ChessRealms.Engine.Common; - -internal static class PieceCharsets -{ - public static class Ascii - { - public const char Pawn = 'p'; - - public const char Knight = 'n'; - - public const char Bishop = 'b'; - - public const char Rook = 'r'; - - public const char Queen = 'q'; - - public const char King = 'k'; - } -} diff --git a/src/ChessRealms.Engine/Common/UnsafeArrays.cs b/src/ChessRealms.Engine/Common/UnsafeArrays.cs deleted file mode 100644 index 40345ac..0000000 --- a/src/ChessRealms.Engine/Common/UnsafeArrays.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ChessRealms.Engine.Common; - -internal static unsafe class UnsafeArrays -{ - public static HashSet ToHashSet(int* ptr, int length) - { - HashSet set = []; - - for (int i = 0; i < length; ++i) - { - set.Add(ptr[i]); - } - - return set; - } -}