diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 4454c87..551ca3c 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -6,6 +6,7 @@ + diff --git a/README.md b/README.md index 87c9a50..58ea648 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ ChessRealms.Engine is a .NET 10 library for standard chess positions and legal play. It uses bitboards and magic attack tables for move generation. The public `ChessGame` API supports FEN import/export, legal moves, promotion, history and -undo, JSON game records, and standard checkmate, stalemate and supported draw -rules. The repository also includes tests, a console game, a perft runner, +undo, canonical SAN formatting and parsing, JSON game records, and standard +checkmate, stalemate and supported draw rules. The repository also includes +tests, a console game, a perft runner, benchmarks and a magic-number search tool. -It does not provide an AI/search engine, UCI, SAN/PGN, Chess960 or tournament +It does not provide an AI/search engine, UCI, PGN, Chess960 or tournament services such as clocks and draw agreements. Dead-position recognition is deliberately incomplete; see the [game API guide](https://github.com/ChessRealms/Engine/blob/main/docs/game-rules-api.md) @@ -57,6 +58,21 @@ promotions require a suffix such as `a7a8q`. The describes ownership, FEN validation, draw claims, repetition and supported rule boundaries. +SAN is available at the text boundary, with the game supplying the position: + +```csharp +var game = new ChessGame(); +CoordinateMove move = game.ParseSan("Nf3"); // Read-only; the move is not played. +string notation = game.FormatSan(move); // Format before making the move. +game.MakeMove(move); +game.MakeSanMove("d5"); // Resolve and play text directly. +``` + +`TryParseSan(ReadOnlySpan, out CoordinateMove)` and +`TryFormatSan(CoordinateMove, Span, out int)` support buffer-based use. +See the [SAN guide](https://github.com/ChessRealms/Engine/blob/main/docs/san.md) +for canonical spelling, state guarantees, historical moves and performance costs. + Save and restore the current move line, including a successful draw claim: ```csharp diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 3ec6227..a1953b1 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -45,6 +45,16 @@ 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. +## Standard Algebraic Notation + +`FormatSan` and `TryFormatSan` format a playable coordinate move in the current +position, before it is applied. `ParseSan` and `TryParseSan` resolve canonical +SAN to a `CoordinateMove` without applying it. `MakeSanMove` resolves and plays +the text through the same move application used by `MakeMove`, avoiding a second +search through the current position's legal moves. All SAN operations reject +finished games. The [SAN guide](san.md) describes exact notation rules, failure +behavior, examples and performance tradeoffs. + ## Game record export and restoration `ExportRecord()` creates an independent, immutable `GameRecord` containing the @@ -196,5 +206,5 @@ fortresses or blocked pawn structures. Lack of a forced mate alone does not end a game. The library supports standard board play, not resignation, draw agreement, -clocks, tournament claim procedures, SAN/PGN, Chess960, AI/search, UCI, +clocks, tournament claim procedures, PGN, Chess960, AI/search, UCI, networking or a graphical interface. diff --git a/docs/san.md b/docs/san.md new file mode 100644 index 0000000..ec62ade --- /dev/null +++ b/docs/san.md @@ -0,0 +1,189 @@ +# Standard Algebraic Notation + +SAN describes a move relative to the position **before** that move. It is useful +for a human-readable move list, text entry and as a building block for PGN. +This API handles one canonical SAN move at a time; it does not import or export +PGN documents. + +`CoordinateMove` remains the move value used by board input, history and game +records. A drag from e2 to e4 already supplies a coordinate move and needs no SAN +conversion. SAN is resolved at the text boundary. There is no separate public +`SanMove`: the text needs a position to identify its source square, while the +resolved value is already represented by `CoordinateMove`. + +## Reading, displaying and playing moves + +```csharp +using ChessRealms.Engine; + +var game = new ChessGame(); +CoordinateMove move = game.ParseSan("Nf3"); // Does not play the move. +string label = game.FormatSan(move); // "Nf3", before applying it. +MoveResult result = game.MakeMove(move); + +// For text entry that should immediately play a move: +MoveResult reply = game.MakeSanMove("d5"); +``` + +The explicit parse-then-apply form is useful for previews and validation. For +immediate application, `MakeSanMove` reuses the resolved internal move instead +of searching the current position's legal moves again. Both application paths +have identical history, repetition, draw, outcome and undo behavior. + +For caller-owned buffers: + +```csharp +var game = new ChessGame(); +ReadOnlySpan input = "Nf3"; +Span output = stackalloc char[7]; + +if (game.TryParseSan(input, out CoordinateMove move) + && game.TryFormatSan(move, output, out int written)) +{ + ReadOnlySpan notation = output[..written]; + // Consume notation here; allocating a string is optional. +} +``` + +Seven characters suffice for any canonical standard-chess SAN move, including +`Qa1xc3+` and `axb8=Q+`. The span API uses characters; there are no SAN UTF-8 +overloads. SAN output is ASCII and culture independent. + +## Canonical notation and strict parsing + +The notation follows [PGN specification section 8.2.3](https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm#c8.2.3): + +- Piece letters are `N`, `B`, `R`, `Q`, `K`; pawn moves omit a piece letter. +- Captures use `x`. Pawn captures include the source file, such as `exd5`. +- Castling is `O-O` or `O-O-O`, using uppercase letters O. +- En passant uses ordinary pawn-capture notation, such as `exd6`. +- Promotion uses `=N`, `=B`, `=R` or `=Q`, including underpromotion. +- Source disambiguation considers only other **legal** moves of the same piece + type to the same target. Use the source file if sufficient, otherwise the + rank, otherwise both. An absolutely pinned alternative does not qualify. +- A checking move ends in `+`, or `#` for mate. Discovered and double checks + also use `+`; a checking suffix cannot replace source disambiguation. +- Stalemate and other drawn endings have no special SAN suffix. A move giving + check while triggering an automatic draw still uses `+` when legal replies + exist on the resulting board. + +Parsing accepts exactly the canonical spelling produced for that move in that +position. Missing, false or incorrect check/mate suffixes and unnecessary source +qualifiers are rejected. For example, from the starting position `Nf3` is +accepted; `Ngf3`, `Ng1f3`, `nf3`, `Nf3+` and `Nf3!` are rejected. + +There is no whitespace trimming, `0-0` alias, omitted capture marker, implicit +promotion, `e.p.` suffix, figurine notation, move number, result token, comment +or annotation support. This is a strict SAN API, not a permissive PGN reader. +PGN import normalization can be designed separately. + +## Failure and state guarantees + +| Operation | Failure behavior | +| --- | --- | +| `FormatSan(move)` | Throws `ArgumentException` for an invalid/unplayable move or a finished game. | +| `TryFormatSan(move, destination, out written)` | Returns false, writes zero characters and leaves the entire destination unchanged for an unplayable move, a finished game or insufficient space. | +| `ParseSan(text)` | Throws `FormatException` for invalid, ambiguous, noncanonical or unplayable SAN, including a finished game. | +| `TryParseSan(text, out move)` | Returns false with `default(CoordinateMove)` for the same inputs. | +| `MakeSanMove(text)` | Returns `MoveResult.None` without changing the game for the same inputs. | + +An empty/default character span, including a null string converted to a span, +is invalid input. Parsing and formatting never change the board, counters, +history, repetition counts, outcome, draw claim or undo stack. They analyze a +value copy of the position. No move is temporarily applied to the original game. + +All operations use the current game's playable-move policy: a finished game +rejects even a move that would otherwise be legal on the board. This includes +claimed and automatic draws. A parsed coordinate move is not a reservation; +`MakeMove` validates it again if applied later. Games remain mutable and are not +thread-safe. + +## Formatting history + +The current position cannot generally format a move that has already been +played. For a single entry, its `FenBefore` supplies the necessary board context: + +```csharp +MoveHistoryEntry entry = game.History[0]; +string san = ChessGame.FromFen(entry.FenBefore).FormatSan(entry.Move); +``` + +For a whole line, replay once from the record's initial position and format +each move before applying it: + +```csharp +GameRecord record = game.ExportRecord(); +var replay = ChessGame.FromFen(record.InitialFen); +var labels = new List(record.Moves.Count); +foreach (CoordinateMove move in record.Moves) +{ + labels.Add(replay.FormatSan(move)); + replay.MakeMove(move); +} +``` + +A draw claim's intended move was not played and is not in `record.Moves`. A +game loaded from FEN only has the history played since that FEN. Formatting +historical moves does not recover earlier moves or encode a game's result. + +## Implementation and costs + +SAN queries generate legal encoded moves into a bounded stack buffer. The +parser reads the input span, filters candidates by the supplied move fields, +then formats only the single selected candidate into a seven-character buffer +to verify canonical spelling. It does not format every legal move. + +Check/mate detection applies the selected move to a value copy of `Position` +without updating counters. It generates opponent replies only if that copy is +in check. It does not clone game history, build FEN, update repetition keys or +use public `GetLegalMoves()` snapshots. Mate detection uses board replies rather +than the public move availability of a game ended by a draw. + +After initialization, successful `TryParseSan`, `ParseSan` and `TryFormatSan` +queries require no managed heap allocations. `FormatSan` allocates the returned +string; throwing APIs allocate exceptions on failure. SAN still costs legal-move +generation and sometimes reply generation, so zero allocation does not imply +zero CPU cost. + +`MakeSanMove` avoids a second current-position search, but its canonical suffix +verification is additional work. For checking moves, reply generation is done +both for SAN verification and by normal move application. Applying a move still +has the existing FEN/history/repetition allocations. Coordinate-based callers +pay no SAN formatting or parsing cost. + +`SanTests` covers canonical fixtures, strict rejection, state preservation, +undo, deterministic playout round trips and allocation checks after warmup. +`SanBenchmarks` compares span/string formatting, parsing, coordinate application, +parse-then-apply and direct SAN application. Run it with: + +```sh +dotnet run --project src/ChessRealms.Engine.Benchmark -c Release -- --filter '*SanBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/san --exporters JSON +``` + +## Local measurements on 2026-10-20 + +The command above was run against this implementation on Windows 11 x64, +SDK 10.0.402 and .NET 10.0.13. All 18 benchmarks completed. Span formatting and +parsing allocated **0 B** in all three scenarios; string formatting allocated +**32 B** for the selected two-to-five-character outputs. Mean span formatting +times ranged from 0.616 to 1.252 microseconds; parsing from 0.734 to 1.306 +microseconds. + +Application measurements include undo to return to the same starting position. +Setup and warmup occur outside measurement, including collection growth. + +| Scenario | Coordinate + undo | Parse + coordinate + undo | Direct SAN + undo | Allocated by each application path | +| --- | ---: | ---: | ---: | ---: | +| Opening (`e4`) | 4.940 us | 5.796 us | 4.885 us | 1048 B | +| Ambiguous (`Nb1d2`) | 3.588 us | 4.355 us | 3.782 us | 720 B | +| Mate (`Qg7#`) | 4.397 us | 5.803 us | 4.801 us | 584 B | + +These are short in-process measurements, not latency guarantees. Processor +metadata was unavailable through CIM, the benchmark could not set the power +plan, and some iterations were below the recommended 100 ms minimum. Small +differences, including the opening direct-SAN result versus coordinate input, +are within measurement variability. Use longer isolated-process runs for +performance decisions. Reports are written under `artifacts/benchmarks/san/`. + +Existing FEN history storage, JSON records and package versioning are unchanged +by the SAN feature. diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1 index 4539c5f..8346935 100644 --- a/scripts/Test-PackageConsumer.ps1 +++ b/scripts/Test-PackageConsumer.ps1 @@ -104,6 +104,25 @@ if (piece != new ChessPiece(PieceColor.White, PieceValue.Pawn)) } game.UndoMove(); + +if (game.FormatSan(move) != "e4" || game.ParseSan("e4") != move) +{ + throw new InvalidOperationException("The package consumer could not format and parse SAN."); +} + +Span san = stackalloc char[7]; +if (!game.TryFormatSan(move, san, out int written) || !san[..written].SequenceEqual("e4") + || !game.TryParseSan(san[..written], out var parsed) || parsed != move + || game.History.Count != 0) +{ + throw new InvalidOperationException("The package consumer could not query SAN through spans."); +} + +if (game.MakeSanMove("e4+") != MoveResult.None || game.MakeSanMove("e4") == MoveResult.None + || !game.UndoMove() || game.ToFen() != ChessGame.StartingFen) +{ + throw new InvalidOperationException("The package consumer could not apply and undo canonical SAN."); +} '@ [System.IO.File]::WriteAllText((Join-Path $consumerRoot "Program.cs"), $program) diff --git a/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs new file mode 100644 index 0000000..7b80d5b --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs @@ -0,0 +1,65 @@ +using BenchmarkDotNet.Attributes; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class SanBenchmarks +{ + private ChessGame game = null!; + private CoordinateMove move; + private string san = null!; + + [Params("Opening", "Ambiguous", "Mate")] + public string Scenario { get; set; } = "Opening"; + + [GlobalSetup] + public void Setup() + { + (string fen, string coordinate, string notation) = Scenario switch + { + "Opening" => (ChessGame.StartingFen, "e2e4", "e4"), + "Ambiguous" => ("7k/8/8/8/8/1N6/8/1N3N1K w - - 0 1", "b1d2", "Nb1d2"), + "Mate" => ("7k/5Q2/6K1/8/8/8/8/8 w - - 0 1", "f7g7", "Qg7#"), + _ => throw new InvalidOperationException("Unknown SAN benchmark scenario.") + }; + game = ChessGame.FromFen(fen); + move = CoordinateMove.Parse(coordinate); + san = notation; + if (game.FormatSan(move) != san || game.ParseSan(san) != move) + { + throw new InvalidOperationException("Invalid SAN benchmark fixture."); + } + } + + [Benchmark] + public string FormatString() => game.FormatSan(move); + + [Benchmark] + public int FormatSpan() + { + Span destination = stackalloc char[7]; + return game.TryFormatSan(move, destination, out int written) ? written : 0; + } + + [Benchmark] + public CoordinateMove ParseSpan() => game.ParseSan(san.AsSpan()); + + [Benchmark] + public MoveResult CoordinateMakeAndUndo() => Undo(game.MakeMove(move)); + + [Benchmark] + public MoveResult ParseMakeAndUndo() => Undo(game.MakeMove(game.ParseSan(san))); + + [Benchmark] + public MoveResult SanMakeAndUndo() => Undo(game.MakeSanMove(san)); + + private MoveResult Undo(MoveResult result) + { + if (result == MoveResult.None || !game.UndoMove()) + { + throw new InvalidOperationException("The benchmark must return to its starting position."); + } + + return result; + } +} diff --git a/src/ChessRealms.Engine.Tests/SanTests.cs b/src/ChessRealms.Engine.Tests/SanTests.cs new file mode 100644 index 0000000..eb786ef --- /dev/null +++ b/src/ChessRealms.Engine.Tests/SanTests.cs @@ -0,0 +1,369 @@ +namespace ChessRealms.Engine.Tests; + +internal class SanTests +{ + private const string ThreeKnights = "7k/8/8/8/8/1N6/8/1N3N1K w - - 0 1"; + private const string PinnedKnight = "7k/8/8/8/1b6/2N5/8/4K1N1 w - - 0 1"; + private const string Castles = "r3k2r/8/8/8/8/8/8/R3K2R"; + private const string Promotion = "7k/P7/8/8/8/8/8/7K w - - 0 1"; + private const string Mate = "7k/5Q2/6K1/8/8/8/8/8 w - - 149 80"; + + private static readonly string[][] CanonicalCases = + [ + [ChessGame.StartingFen, "e2e4", "e4"], + [ChessGame.StartingFen, "g1f3", "Nf3"], + ["7k/8/8/3p4/4P3/8/8/7K w - - 0 1", "e4d5", "exd5"], + ["7k/8/8/3p4/4P3/8/8/7K b - - 0 1", "d5e4", "dxe4"], + ["7k/8/8/3p4/8/2N5/8/7K w - - 0 1", "c3d5", "Nxd5"], + ["7k/p7/8/8/8/8/8/2B4K w - - 0 1", "c1e3", "Be3"], + ["7k/8/8/8/8/8/8/R6K w - - 0 1", "h1g2", "Kg2"], + ["7k/8/8/8/8/8/8/R6K w - - 0 1", "a1a8", "Ra8+"], + [Mate, "f7g7", "Qg7#"], + [Mate, "f7e6", "Qe6"], + [Castles + " w KQkq - 0 1", "e1g1", "O-O"], + [Castles + " w KQkq - 0 1", "e1c1", "O-O-O"], + [Castles + " b KQkq - 0 1", "e8g8", "O-O"], + [Castles + " b KQkq - 0 1", "e8c8", "O-O-O"], + ["5k2/8/8/8/8/8/8/4K2R w K - 0 1", "e1g1", "O-O+"], + ["3k4/8/8/8/8/8/8/R3K3 w Q - 0 1", "e1c1", "O-O-O+"], + ["7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "e5d6", "exd6"], + ["7k/8/8/8/3Pp3/8/8/7K b - d3 0 1", "e4d3", "exd3"], + ["8/8/8/R2pP2k/8/8/8/7K w - d6 0 1", "e5d6", "exd6+"], + ["4k3/p7/8/8/8/8/4B3/4R2K w - - 0 1", "e2b5", "Bb5+"], + [PinnedKnight, "g1e2", "Ne2"], + ["7k/8/8/8/8/8/8/1N3N1K w - - 0 1", "b1d2", "Nbd2"], + ["7k/8/8/8/8/8/8/1N3N1K w - - 0 1", "f1d2", "Nfd2"], + ["7k/8/8/8/8/5N2/8/1N5K w - - 0 1", "b1d2", "Nbd2"], + ["7k/8/8/8/8/1N6/8/1N5K w - - 0 1", "b1d2", "N1d2"], + ["7k/8/8/8/8/1N6/8/1N5K w - - 0 1", "b3d2", "N3d2"], + [ThreeKnights, "b1d2", "Nb1d2"], + [ThreeKnights, "b3d2", "N3d2"], + [ThreeKnights, "f1d2", "Nfd2"], + ["7k/8/8/8/8/Q1p5/8/Q1Q4K w - - 0 1", "a1c3", "Qa1xc3+"], + [Promotion, "a7a8q", "a8=Q+"], + [Promotion, "a7a8r", "a8=R+"], + [Promotion, "a7a8b", "a8=B"], + [Promotion, "a7a8n", "a8=N"], + ["1r5k/P7/8/8/8/8/8/7K w - - 0 1", "a7b8q", "axb8=Q+"], + ["7k/8/8/8/8/8/p7/1R5K b - - 0 1", "a2b1r", "axb1=R+"], + ["7k/8/8/8/8/8/p7/7K b - - 0 1", "a2a1n", "a1=N"], + ["8/P1k5/8/8/8/8/8/7K w - - 0 1", "a7a8n", "a8=N+"], + ["4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "e3d5", "Ned5+"], + ["4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "c3d5", "Ncd5"], + ["7k/8/8/8/8/8/8/R6K w - - 149 80", "a1a8", "Ra8+"], + ["7k/8/8/8/8/8/8/R6K b - - 0 999999999999999999999999", "h8g8", "Kg8"] + ]; + + [TestCaseSource(nameof(CanonicalCases))] + public void CanonicalNotation_FormatsParsesAndAppliesWithoutChangingQueries(string fen, string coordinate, string san) + { + var game = ChessGame.FromFen(fen); + var before = game.Clone(); + var move = CoordinateMove.Parse(coordinate); + Assert.That(game.FormatSan(move), Is.EqualTo(san)); + + Span buffer = stackalloc char[9]; + buffer.Fill('!'); + Assert.That(game.TryFormatSan(move, buffer[..san.Length], out int written), Is.True); + Assert.That(written, Is.EqualTo(san.Length)); + Assert.That(buffer[..written].ToString(), Is.EqualTo(san)); + Assert.That(buffer[written..].ToString(), Is.EqualTo(new string('!', 9 - written))); + Assert.That(game.ParseSan(san), Is.EqualTo(move)); + Assert.That(game.TryParseSan(san, out var parsed), Is.True); + Assert.That(parsed, Is.EqualTo(move)); + AssertEquivalent(before, game); + + MoveResult expected = before.MakeMove(move); + Assert.That(expected, Is.Not.EqualTo(MoveResult.None)); + Assert.That(game.MakeSanMove(san), Is.EqualTo(expected)); + AssertEquivalent(before, game); + Assert.That(game.UndoMove(), Is.True); + Assert.That(before.UndoMove(), Is.True); + AssertEquivalent(before, game); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("e4 ")] + [TestCase(" e4")] + [TestCase("e4\n")] + [TestCase("E4")] + [TestCase("nf3")] + [TestCase("Pe4")] + [TestCase("e2e4")] + [TestCase("Ng1f3")] + [TestCase("Ngf3")] + [TestCase("N1f3")] + [TestCase("Nxf3")] + [TestCase("Nf3+")] + [TestCase("e4#")] + [TestCase("e4!")] + [TestCase("e4?!")] + [TestCase("e4$1")] + [TestCase("e4 e5")] + [TestCase("1.e4")] + [TestCase("e5")] + [TestCase("e4=Q")] + [TestCase("Nf3=Q")] + [TestCase("0-0")] + [TestCase("O-O")] + [TestCase("O-O-O")] + [TestCase("♘f3")] + [TestCase("e4")] + [TestCase("e4\0")] + [TestCase("--")] + [TestCase("*")] + public void InvalidStartingNotation_IsRejectedWithoutMutation(string? text) + { + AssertRejected(new ChessGame(), text); + } + + [TestCase(ThreeKnights, "Nd2")] + [TestCase(ThreeKnights, "Nbd2")] + [TestCase(ThreeKnights, "N1d2")] + [TestCase(ThreeKnights, "Nb3d2")] + [TestCase(PinnedKnight, "Nge2")] + [TestCase(PinnedKnight, "Nce2")] + [TestCase(Promotion, "a8")] + [TestCase(Promotion, "a8Q+")] + [TestCase(Promotion, "a8=q+")] + [TestCase(Promotion, "a8=P")] + [TestCase(Promotion, "a8=K")] + [TestCase(Promotion, "a8=Q")] + [TestCase(Promotion, "a8=Q#")] + [TestCase(Promotion, "a8=Q++")] + [TestCase(Mate, "Qg7")] + [TestCase(Mate, "Qg7+")] + [TestCase(Mate, "Qe6=")] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "ed6")] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "exd6 e.p.")] + [TestCase("7k/8/8/r4pPK/8/8/8/8 w - f6 0 1", "gxf6")] + [TestCase("4kr2/8/8/8/8/8/8/R3K2R w KQ - 0 1", "O-O")] + // Both knights can play d5, but only e3d5 uncovers the rook's check. + [TestCase("4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "Nd5+")] + public void NoncanonicalAmbiguousOrIllegalNotation_IsRejected(string fen, string text) + { + AssertRejected(ChessGame.FromFen(fen), text); + } + + [TestCaseSource(nameof(CanonicalCases))] + public void ShortDestinations_AreNeverPartiallyWritten(string fen, string coordinate, string san) + { + var game = ChessGame.FromFen(fen); + var before = game.Clone(); + Span buffer = stackalloc char[7]; + for (int length = 0; length < san.Length; ++length) + { + buffer.Fill('!'); + Assert.That(game.TryFormatSan(CoordinateMove.Parse(coordinate), buffer[..length], out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!")); + } + + AssertEquivalent(before, game); + } + + [Test] + public void InvalidCoordinateMoves_CannotBeFormatted() + { + var game = new ChessGame(); + var before = game.Clone(); + CoordinateMove[] moves = [default, CoordinateMove.Parse("e2e5"), CoordinateMove.Parse("e2e4q"), + CoordinateMove.Parse("e7e5"), CoordinateMove.Parse("e1g1")]; + Span buffer = stackalloc char[7]; + foreach (var move in moves) + { + buffer.Fill('!'); + Assert.That(game.TryFormatSan(move, buffer, out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!")); + Assert.Throws(() => game.FormatSan(move)); + } + + AssertEquivalent(before, game); + } + + [TestCase("7k/6Q1/6K1/8/8/8/8/8 b - - 0 1", "h8g8", "Kg8")] + [TestCase("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", "h8g8", "Kg8")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1", "h1g1", "Kg1")] + [TestCase("7k/8/8/8/8/8/8/R6K w - - 150 1", "a1a2", "Ra2")] + public void FinishedPositions_RejectAllSanOperations(string fen, string coordinate, string san) + { + var game = ChessGame.FromFen(fen); + Assert.That(game.IsFinished, Is.True); + AssertFinished(game, coordinate, san); + } + + [TestCase(false)] + [TestCase(true)] + public void Queries_PreserveRepetitionClaimsHistoryAndEveryUndo(bool claim) + { + var game = new ChessGame(); + Play(game, "g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1"); + if (claim) + { + Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition, CoordinateMove.Parse("f6g8")), Is.True); + AssertFinished(game, "f6g8", "Ng8"); + } + else + { + var move = CoordinateMove.Parse("f6g8"); + var before = game.Clone(); + Assert.That(game.ParseSan("Ng8"), Is.EqualTo(move)); + Assert.That(game.FormatSan(move), Is.EqualTo("Ng8")); + AssertRejected(game, "Ng8+"); + AssertEquivalent(before, game); + AssertUndoEquivalent(before, game); + } + } + + [Test] + public void FivefoldRepetition_RejectsFurtherSanMoves() + { + var game = new ChessGame(); + for (int i = 0; i < 4; ++i) + { + Play(game, "g1f3 g8f6 f3g1 f6g8"); + } + + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + AssertFinished(game, "g1f3", "Nf3"); + } + + [Test] + public void ClaimWithoutHistory_RemainsIntactAfterQueriesAndFailedUndo() + { + var game = ChessGame.FromFen("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1a2")), Is.True); + AssertFinished(game, "a1a2", "Ra2"); + } + + [Test] + public void SpanInput_UsesOnlyTheSuppliedSlice() + { + var game = new ChessGame(); + ReadOnlySpan input = "prefixNf3suffix"; + Assert.That(game.ParseSan(input.Slice(6, 3)), Is.EqualTo(CoordinateMove.Parse("g1f3"))); + Assert.That(game.History, Is.Empty); + } + + [TestCase(17)] + [TestCase(12345)] + public void DeterministicGames_RoundTripEveryLegalMoveAndPreserveState(int seed) + { + var game = new ChessGame(); + var random = new Random(seed); + for (int ply = 0; ply < 100 && !game.IsFinished; ++ply) + { + var before = game.Clone(); + var moves = game.GetLegalMoves(); + var notation = new HashSet(StringComparer.Ordinal); + foreach (var move in moves) + { + string san = game.FormatSan(move); + Assert.That(notation.Add(san), Is.True, san); + Assert.That(game.ParseSan(san), Is.EqualTo(move), san); + var copy = game.Clone(); + var result = copy.MakeMove(move); + Assert.That(san.EndsWith('#'), Is.EqualTo(result.HasFlag(MoveResult.Checkmate))); + Assert.That(san.EndsWith('+'), Is.EqualTo(result.HasFlag(MoveResult.Check) + && !result.HasFlag(MoveResult.Checkmate))); + } + + AssertEquivalent(before, game); + var chosen = moves[random.Next(moves.Count)]; + string text = game.FormatSan(chosen); + Assert.That(game.MakeSanMove(text), Is.EqualTo(before.MakeMove(chosen))); + AssertEquivalent(before, game); + } + } + + [TestCaseSource(nameof(CanonicalCases))] + public void SpanQueries_DoNotAllocateAfterWarmup(string fen, string coordinate, string san) + { + var game = ChessGame.FromFen(fen); + var move = CoordinateMove.Parse(coordinate); + Span buffer = stackalloc char[7]; + for (int i = 0; i < 64; ++i) + { + game.TryFormatSan(move, buffer, out _); + game.TryParseSan(san, out _); + } + + long start = GC.GetAllocatedBytesForCurrentThread(); + bool success = true; + for (int i = 0; i < 128; ++i) + { + success &= game.TryFormatSan(move, buffer, out _); + success &= game.TryParseSan(san, out var parsed) && parsed == move; + success &= !game.TryParseSan("invalid", out _); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - start; + Assert.That(success, Is.True); + Assert.That(allocated, Is.Zero); + } + + private static void AssertRejected(ChessGame game, string? text) + { + var before = game.Clone(); + Assert.That(game.TryParseSan(text, out var move), Is.False); + Assert.That(move, Is.EqualTo(default(CoordinateMove))); + Assert.Throws(() => game.ParseSan(text)); + Assert.That(game.MakeSanMove(text), Is.EqualTo(MoveResult.None)); + AssertEquivalent(before, game); + } + + private static void AssertFinished(ChessGame game, string coordinate, string san) + { + var before = game.Clone(); + var move = CoordinateMove.Parse(coordinate); + Span buffer = stackalloc char[7]; + buffer.Fill('!'); + Assert.That(game.TryFormatSan(move, buffer, out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!")); + Assert.Throws(() => game.FormatSan(move)); + AssertRejected(game, san); + AssertEquivalent(before, game); + AssertUndoEquivalent(before, game); + } + + private static void AssertEquivalent(ChessGame expected, ChessGame actual) + { + Assert.Multiple(() => + { + Assert.That(actual.Position, Is.EqualTo(expected.Position)); + Assert.That(actual.ToFen(), Is.EqualTo(expected.ToFen())); + Assert.That(actual.Outcome, Is.EqualTo(expected.Outcome)); + Assert.That(actual.State, Is.EqualTo(expected.State)); + Assert.That(actual.History, Is.EqualTo(expected.History)); + Assert.That(actual.RepetitionCount, Is.EqualTo(expected.RepetitionCount)); + Assert.That(actual.AvailableDrawClaims, Is.EqualTo(expected.AvailableDrawClaims)); + Assert.That(actual.DrawIntendedMove, Is.EqualTo(expected.DrawIntendedMove)); + Assert.That(actual.GetLegalMoves(), Is.EqualTo(expected.GetLegalMoves())); + Assert.That(GameRecord.Serialize(actual.ExportRecord()), Is.EqualTo(GameRecord.Serialize(expected.ExportRecord()))); + }); + } + + private static void AssertUndoEquivalent(ChessGame expected, ChessGame actual) + { + while (expected.UndoMove()) + { + Assert.That(actual.UndoMove(), Is.True); + AssertEquivalent(expected, actual); + } + + Assert.That(actual.UndoMove(), Is.False); + AssertEquivalent(expected, actual); + } + + private static void Play(ChessGame game, string moves) + { + foreach (string move in moves.Split(' ')) + { + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.Not.EqualTo(MoveResult.None)); + } + } +} diff --git a/src/ChessRealms.Engine/ChessGame.San.cs b/src/ChessRealms.Engine/ChessGame.San.cs new file mode 100644 index 0000000..e62979b --- /dev/null +++ b/src/ChessRealms.Engine/ChessGame.San.cs @@ -0,0 +1,319 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Core.Movements; +using ChessRealms.Engine.Core.Types; + +namespace ChessRealms.Engine; + +public sealed partial class ChessGame +{ + // Canonical SAN is at most seven ASCII characters, for example Qa6xb7#. + private const int MaxSanLength = 7; + + /// Formats a playable move as canonical Standard Algebraic Notation in the current position. + /// Call before applying the move. Does not change the game. + /// The move is invalid, illegal, or the game is finished. + public string FormatSan(CoordinateMove move) + { + Span text = stackalloc char[MaxSanLength]; + return TryFormatSan(move, text, out int written) + ? new string(text[..written]) + : throw new ArgumentException("The move must be playable in the current game.", nameof(move)); + } + + /// Writes canonical SAN for a playable move into a character span without allocating a string. + /// Uses the position before the move and does not change the game. Returns false with zero + /// characters written and leaves the destination unchanged for an invalid or illegal move, a finished + /// game, or insufficient space. Seven characters suffice for any canonical SAN move. + public bool TryFormatSan(CoordinateMove move, Span destination, out int charsWritten) + { + charsWritten = 0; + if (IsFinished || !move.IsValid || destination.Length < 2) + { + return false; + } + + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int count = WriteLegalMoves(position, legalMoves); + if (!TryFindMove(move, legalMoves[..count], out int encodedMove)) + { + return false; + } + + Span text = stackalloc char[MaxSanLength]; + int length = WriteSan(position, encodedMove, legalMoves[..count], text); + if (destination.Length < length) + { + return false; + } + + text[..length].CopyTo(destination); + charsWritten = length; + return true; + } + + /// Resolves canonical SAN to a legal coordinate move without changing the game. + /// Requires exact case, minimal disambiguation, and the correct check or mate suffix. + /// Whitespace, annotations and alternative spellings are not accepted. + /// The text is not canonical SAN for a playable move in this game. + public CoordinateMove ParseSan(ReadOnlySpan text) + => TryParseSan(text, out var move) ? move : throw new FormatException("Expected canonical SAN for a playable move."); + + /// Tries to resolve canonical SAN to a legal coordinate move without changing the game. + /// Returns false and a default move for malformed, ambiguous, noncanonical or illegal input, + /// or a finished game. The check or mate suffix must be exact. Does not allocate strings. + public bool TryParseSan(ReadOnlySpan text, out CoordinateMove move) + { + move = default; + if (!TryFindSanMove(text, out int encodedMove)) + { + return false; + } + + move = ToPublicMove(encodedMove); + return true; + } + + /// Resolves and plays canonical SAN without repeating the current-position legal-move search. + /// Uses the same notation rules as ParseSan and the same history and outcome rules as MakeMove. + /// Returns MoveResult.None without changing the game for invalid input or a finished game. + public MoveResult MakeSanMove(ReadOnlySpan text) + => TryFindSanMove(text, out int encodedMove) + ? ApplyLegalMove(ToPublicMove(encodedMove), encodedMove) + : MoveResult.None; + + private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove) + { + encodedMove = 0; + if (IsFinished || !TryReadSanPattern(text, out var pattern)) + { + return false; + } + + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int count = WriteLegalMoves(position, legalMoves); + int selected = 0; + foreach (int candidate in legalMoves[..count]) + { + if (!pattern.Matches(candidate)) + { + continue; + } + + // A check suffix cannot disambiguate two otherwise matching moves. + if (selected != 0) + { + return false; + } + + selected = candidate; + } + + if (selected == 0) + { + return false; + } + + // Only the selected move needs check/mate evaluation. This also rejects + // unnecessary source qualifiers and all noncanonical spellings. + Span canonical = stackalloc char[MaxSanLength]; + int length = WriteSan(position, selected, legalMoves[..count], canonical); + if (!text.SequenceEqual(canonical[..length])) + { + return false; + } + + encodedMove = selected; + return true; + } + + private static int WriteSan(Position before, int move, ReadOnlySpan legalMoves, Span text) + { + int length = 0; + int castling = BinaryMoveOps.DecodeCastling(move); + if (castling != Castlings.None) + { + ReadOnlySpan castle = (castling & (Castlings.WK | Castlings.BK)) != 0 ? "O-O" : "O-O-O"; + castle.CopyTo(text); + length = castle.Length; + } + else + { + int piece = BinaryMoveOps.DecodeSrcPiece(move); + int source = BinaryMoveOps.DecodeSrc(move); + int target = BinaryMoveOps.DecodeTrg(move); + bool capture = BinaryMoveOps.DecodeCapture(move) != 0; + if (piece != Pieces.Pawn) + { + text[length++] = "PNBRQK"[piece]; + bool alternative = false; + bool sameFile = false; + bool sameRank = false; + foreach (int candidate in legalMoves) + { + int otherSource = BinaryMoveOps.DecodeSrc(candidate); + if (otherSource == source || BinaryMoveOps.DecodeSrcPiece(candidate) != piece + || BinaryMoveOps.DecodeTrg(candidate) != target) + { + continue; + } + + alternative = true; + sameFile |= otherSource % 8 == source % 8; + sameRank |= otherSource / 8 == source / 8; + } + + if (alternative) + { + if (!sameFile || sameRank) + { + text[length++] = (char)('a' + source % 8); + } + + if (sameFile) + { + text[length++] = (char)('1' + source / 8); + } + } + } + else if (capture) + { + text[length++] = (char)('a' + source % 8); + } + + if (capture) + { + text[length++] = 'x'; + } + + text[length++] = (char)('a' + target % 8); + text[length++] = (char)('1' + target / 8); + int promotion = BinaryMoveOps.DecodePromotion(move); + if (promotion != Promotions.None) + { + text[length++] = '='; + text[length++] = "PNBRQK"[promotion]; + } + } + + // SAN needs board geometry, not counters or repetition history. In + // particular an automatic draw with check must not be mistaken for mate. + var after = before; + MoveDriver.MakeMove(ref after, move, updateCounters: false); + after.SwitchColor(); + if (after.IsKingChecked(after.color)) + { + Span replies = stackalloc int[MoveGen.MaxMoves]; + text[length++] = WriteLegalMoves(after, replies) == 0 ? '#' : '+'; + } + + return length; + } + + private static bool TryReadSanPattern(ReadOnlySpan text, out SanPattern pattern) + { + pattern = default; + if (text.Length is < 2 or > MaxSanLength) + { + return false; + } + + if (text[^1] is '+' or '#') + { + text = text[..^1]; + } + + if (text.SequenceEqual("O-O") || text.SequenceEqual("O-O-O")) + { + int rights = text.Length == 3 ? Castlings.WK | Castlings.BK : Castlings.WQ | Castlings.BQ; + pattern = new(rights, Pieces.King, 0, Promotions.None, false, -1, -1); + return true; + } + + int promotion = Promotions.None; + if (text.Length >= 2 && text[^2] == '=') + { + promotion = ReadSanPiece(text[^1]); + if (!Promotions.IsValid(promotion)) + { + return false; + } + + text = text[..^2]; + } + + if (text.Length < 2 || !Square.TryParse(text[^2..], out var target)) + { + return false; + } + + var prefix = text[..^2]; + int piece = prefix.IsEmpty ? Pieces.None : ReadSanPiece(prefix[0]); + if (piece != Pieces.None) + { + prefix = prefix[1..]; + } + else + { + piece = Pieces.Pawn; + } + + bool capture = !prefix.IsEmpty && prefix[^1] == 'x'; + if (capture) + { + prefix = prefix[..^1]; + } + + int file = -1; + int rank = -1; + if (!prefix.IsEmpty && prefix[0] is >= 'a' and <= 'h') + { + file = prefix[0] - 'a'; + prefix = prefix[1..]; + } + + if (!prefix.IsEmpty && prefix[0] is >= '1' and <= '8') + { + rank = prefix[0] - '1'; + prefix = prefix[1..]; + } + + if (!prefix.IsEmpty || (piece == Pieces.Pawn && (rank != -1 || capture != (file != -1))) + || (piece != Pieces.Pawn && promotion != Promotions.None)) + { + return false; + } + + pattern = new(Castlings.None, piece, target.Index, promotion, capture, file, rank); + return true; + } + + private static int ReadSanPiece(char symbol) => symbol switch + { + 'N' => Pieces.Knight, + 'B' => Pieces.Bishop, + 'R' => Pieces.Rook, + 'Q' => Pieces.Queen, + 'K' => Pieces.King, + _ => Pieces.None + }; + + private readonly record struct SanPattern(int Castling, int Piece, int Target, int Promotion, + bool Capture, int File, int Rank) + { + public bool Matches(int move) + { + int castling = BinaryMoveOps.DecodeCastling(move); + if (Castling != Castlings.None) + { + return (castling & Castling) != 0; + } + + int source = BinaryMoveOps.DecodeSrc(move); + return castling == Castlings.None && BinaryMoveOps.DecodeSrcPiece(move) == Piece + && BinaryMoveOps.DecodeTrg(move) == Target && BinaryMoveOps.DecodePromotion(move) == Promotion + && (BinaryMoveOps.DecodeCapture(move) != 0) == Capture + && (File == -1 || source % 8 == File) && (Rank == -1 || source / 8 == Rank); + } + } +} diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index e033126..fd2aa78 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -9,7 +9,7 @@ namespace ChessRealms.Engine; /// A mutable game with exclusive history ownership. Use Clone for independent analysis. -public sealed class ChessGame +public sealed partial class ChessGame { /// The FEN for the standard starting position. public const string StartingFen = FenStrings.StartPosition; @@ -186,6 +186,11 @@ public MoveResult MakeMove(CoordinateMove move) return MoveResult.None; } + return ApplyLegalMove(move, encodedMove); + } + + private MoveResult ApplyLegalMove(CoordinateMove move, int encodedMove) + { var nextPosition = position; MoveDriver.MakeMove(ref nextPosition, encodedMove); nextPosition.SwitchColor(); @@ -309,7 +314,13 @@ private bool TryFindMove(CoordinateMove move, out int encodedMove) Span legalMoves = stackalloc int[MoveGen.MaxMoves]; int legalMoveCount = WriteLegalMoves(position, legalMoves); - foreach (int candidate in legalMoves[..legalMoveCount]) + return TryFindMove(move, legalMoves[..legalMoveCount], out encodedMove); + } + + private static bool TryFindMove(CoordinateMove move, ReadOnlySpan legalMoves, out int encodedMove) + { + encodedMove = 0; + foreach (int candidate in legalMoves) { if (ToPublicMove(candidate) != move) {