From 68b15d05be33a1a90c0a28b9394d358d250172cd Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 22 Oct 2026 03:40:18 +0300 Subject: [PATCH 1/4] perf(game): optimize FEN output and record JSON restoration --- README.md | 6 +- docs/game-rules-api.md | 60 ++++- docs/performance.md | 42 ++- scripts/Test-PackageConsumer.ps1 | 41 +++ .../AllocationProbe.cs | 37 +++ .../FenBenchmarks.cs | 51 ++++ .../HistoryBenchmarks.cs | 103 +++++++ .../HistoryMemoryProbe.cs | 70 +++++ .../PgnBenchmarks.cs | 2 +- src/ChessRealms.Engine.Benchmark/Program.cs | 6 + .../RecordJsonBenchmarks.cs | 69 +++++ .../HistoryFenTests.cs | 251 ++++++++++++++++++ .../Json/GameRecordJsonTests.cs | 82 +++++- .../Parsing/FenFormattingTests.cs | 147 ++++++++++ src/ChessRealms.Engine/ChessGame.History.cs | 74 ++++++ src/ChessRealms.Engine/ChessGame.cs | 31 ++- src/ChessRealms.Engine/GameRecord.cs | 1 + .../Json/GameRecordJsonSerializer.cs | 10 +- src/ChessRealms.Engine/Parsing/FenStrings.cs | 128 +++++++-- 19 files changed, 1151 insertions(+), 60 deletions(-) create mode 100644 src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs create mode 100644 src/ChessRealms.Engine.Benchmark/HistoryBenchmarks.cs create mode 100644 src/ChessRealms.Engine.Benchmark/HistoryMemoryProbe.cs create mode 100644 src/ChessRealms.Engine.Benchmark/RecordJsonBenchmarks.cs create mode 100644 src/ChessRealms.Engine.Tests/HistoryFenTests.cs create mode 100644 src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs create mode 100644 src/ChessRealms.Engine/ChessGame.History.cs diff --git a/README.md b/README.md index fca26ad..fde15ed 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,13 @@ Save and restore the current move line, including a successful draw claim: ```csharp string json = GameRecord.Serialize(game.ExportRecord()); -GameRecord record = GameRecord.Deserialize(json); -ChessGame restored = ChessGame.FromRecord(record); +ChessGame restored = ChessGame.FromRecordJson(json); ``` JSON records include a schema version, initial FEN, coordinate moves and an -optional draw claim. Import validates the game by replaying its moves. The +optional draw claim. `FromRecordJson` validates and restores the game in one replay. The restored game preserves history, repetition counts and undo support. +Use `GameRecord.Deserialize` when an immutable record is needed instead of a game. The package intentionally exposes only the high-level types in the `ChessRealms.Engine` namespace. Bitboards, encoded moves, magic tables, raw diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 3aa0c53..4609781 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -28,7 +28,8 @@ pseudolegal moves are filtered for king safety. move, to identify the winner. - `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 + moves with the move, FEN before/after and move flags. `PlyCount` reads the number + of recorded half-moves without constructing a history snapshot. `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 is no move to undo. A claim itself is not a move and cannot be undone when @@ -45,6 +46,39 @@ 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 output + +`ToFen()` returns the current position as a canonical FEN string. After a move, +the string is formatted on first request and cached for that position. +`TryFormatFen(Span destination, out int charsWritten)` writes the same text +into a caller-owned buffer without creating a FEN string. Both work after the +game finishes and leave its position, history and outcome unchanged. + +If the buffer is too small, `TryFormatFen` returns false, sets `charsWritten` to +zero and leaves the entire destination unchanged. On success, only the written +prefix is modified. FEN uses invariant ASCII notation. Move counters have +arbitrary precision, so callers must accommodate their size rather than assume +a fixed maximum FEN length. + +`GetFenAtPly(int ply)` returns FEN for one position in the current recorded line. +Zero selects the initial position, even when the game started from a custom FEN; +`PlyCount` selects the current position. A ply counts one recorded half-move, +independently of the fullmove number in FEN. For history entry `i`, ply `i` is +the position before the move and ply `i + 1` is the position after it. + +`TryFormatFenAtPly(int ply, Span destination, out int charsWritten)` writes +that position directly to a caller-owned buffer without creating or caching a +FEN string. An out-of-range ply makes `GetFenAtPly` throw +`ArgumentOutOfRangeException`; the span method returns false with zero written +and leaves the destination unchanged. The same span failure contract applies +when the buffer is too small. + +Individual string queries format and cache only the selected position. Reading +`History` materializes all before/after strings, reusing any already cached +strings. Its public entries retain their value semantics and remain unchanged +after later moves, undo or a different continuation. Historical queries on the +game follow its current line; undone positions are no longer addressable there. + ## Standard Algebraic Notation `FormatSan` and `TryFormatSan` format a playable coordinate move in the current @@ -107,15 +141,21 @@ whole record succeeds; it does not change the record or the original game. ## JSON game records -Use the static `GameRecord.Serialize` and `GameRecord.Deserialize` methods to -save and load records: +Use `GameRecord.Serialize` to save a record and `ChessGame.FromRecordJson` to +restore a playable game directly: ```csharp string json = GameRecord.Serialize(game.ExportRecord()); -GameRecord loaded = GameRecord.Deserialize(json); -ChessGame restored = ChessGame.FromRecord(loaded); +ChessGame restored = ChessGame.FromRecordJson(json); ``` +`FromRecordJson(string json)` validates and restores the entire game in one +replay, returning an independent instance with history, repetition counts, +outcome, draw claims and undo support. It accepts the same versioned record +format as `GameRecord.Deserialize`. Use `GameRecord.Deserialize(json)` when an +immutable record is needed; `ChessGame.FromRecord(record)` remains available +for records already in memory. + The version-one format is: ```json @@ -157,10 +197,12 @@ supply it. It is independent of the package version: package `1.1.0` does not imply schema version `1.1.0`. A missing or unsupported schema version is rejected; no migration of other versions is currently provided. -Deserialization checks the document and validates the entire game through the -same replay used by `ChessGame.FromRecord`, including draw claims. It returns an -immutable record only after all checks succeed. Calling `FromRecord` afterwards -replays it again to create the game to use. Invalid JSON, unsupported versions, +Both JSON entry points check the document and validate the entire game through +the same replay used by `ChessGame.FromRecord`, including draw claims. They +return a result only after all checks succeed. `GameRecord.Deserialize` returns +an immutable record; calling `FromRecord` afterwards replays it again. +`ChessGame.FromRecordJson` returns the game built during validation directly. +Invalid JSON, unsupported versions, invalid FEN, unplayable moves and unavailable claims throw `JsonException`. Replay errors retain the original `FormatException` as their inner exception; move errors include their zero-based index. Null method arguments throw diff --git a/docs/performance.md b/docs/performance.md index bd4747f..7040b4d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -5,7 +5,7 @@ BenchmarkDotNet. Run from the repository root after restoring dependencies: ```sh dotnet build src/ChessRealms.Engine.Benchmark -c Release --no-restore -dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*CoreBenchmarks*' '*GameApiBenchmarks*' '*SanBenchmarks*' '*PgnBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 8 --iterationTime 150 --artifacts artifacts/benchmarks/local --exporters JSON +dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*CoreBenchmarks*' '*FenBenchmarks*' '*GameApiBenchmarks*' '*HistoryBenchmarks*' '*RecordJsonBenchmarks*' '*SanBenchmarks*' '*PgnBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 8 --iterationTime 150 --artifacts artifacts/benchmarks/local --exporters JSON ``` Use a single filter to focus on one suite: @@ -13,7 +13,10 @@ Use a single filter to focus on one suite: | Suite | Coverage | | --- | --- | | `CoreBenchmarks` | Pseudolegal generation, depth-three perft and FEN formatting for the start position and Kiwipete. | +| `FenBenchmarks` | Fresh string and span formatting, and the first four FEN fields, for the start position, Kiwipete, en passant and large counters. | | `GameApiBenchmarks` | Legal moves, FEN, history, cloning, move + undo, records and JSON at 0 and 20 plies. | +| `HistoryBenchmarks` | Replay with no FEN reads, per-move FEN/history reads, a final history read, warmed snapshots, cloning, historical FEN and move + undo at 0, 20 and 120 plies. | +| `RecordJsonBenchmarks` | Deserialize + restore versus direct JSON restoration, both alone and followed by a move and JSON export, at 0, 20 and 120 plies. | | `SanBenchmarks` | Span/string formatting, parsing and move + undo for opening, ambiguous and mating moves. | | `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies. | @@ -24,6 +27,16 @@ and collection growth. Export to `TextWriter.Null` excludes output storage, encoding and I/O. Running without arguments starts the existing depth-six perft benchmark, which can take longer. +History scenarios use prefixes of the same validated PGN line. Replay creates +a fresh game per operation. Snapshot and individual historical FEN queries use +a game whose strings have already been requested during setup. The move + undo +cases distinguish applying a move alone from reading its FEN before undoing it. + +JSON restoration scenarios use the same line and compare equivalent results. +Each operation creates a fresh game; input JSON and the next legal move are +prepared during setup. Move-and-save cases include record export and JSON +serialization, but exclude database, network and other server work. + For longer measurements in isolated benchmark processes, omit `--inProcess` and the short iteration settings. Compare the same fixtures on the same machine and runtime, and inspect confidence intervals and benchmark warnings before @@ -41,6 +54,18 @@ cross-check small allocation readings from the in-process benchmark harness; its different warmup duration means results need not match BDN byte for byte. It does not measure execution time, retained memory or peak live heap. +For a separate approximate retained-heap comparison: + +```sh +dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --history-memory-probe +``` + +This retains batches of 256 games at 0, 20 and 120 plies, measuring the heap +after full collections. It distinguishes games with no FEN queries, a current +FEN query, and a complete history query. Returned history snapshots are not kept. +Use the same runtime and fixture for comparisons; GC heap deltas include noise +and are not exact object-size or peak-memory measurements. + Keep generated reports and temporary audit notes under `artifacts/benchmarks/`, which Git ignores. Record the revision, runtime and command with results used in a PR. Rerun relevant scenarios after changes instead of treating historical @@ -49,7 +74,18 @@ numbers as current API guarantees. ## API usage `History`, `GetLegalMoves()` and `Clone()` return independently owned snapshots -or games. Read a history snapshot once before traversing it. `ToFen()` reuses an -existing FEN string; `FenBefore` references the preceding position's string. +or games. Read a history snapshot once before traversing it. Use `PlyCount` for +the recorded half-move count without materializing history. `ToFen()` and +`GetFenAtPly()` format and cache a position's FEN on first request. `History` +materializes all entries, reusing those caches; `FenBefore` references the +preceding position's string. +`TryFormatFen` writes to a caller-owned character span; `FenBenchmarks` measures +formatting separately from reading the cached string through `ToFen()`. +`TryFormatFenAtPly` writes a single historical position without populating the +string cache or materializing history. +Use `ChessGame.FromRecordJson` to restore a playable game directly from record +JSON with one validated replay. `GameRecord.Deserialize` followed by +`ChessGame.FromRecord` performs two replays; keep that path when the separate +record is needed for its own purpose. The [SAN guide](san.md) covers span queries and text application; the [PGN guide](pgn.md) covers model ownership, export and restoration. diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1 index fe13d43..927b0bf 100644 --- a/scripts/Test-PackageConsumer.ps1 +++ b/scripts/Test-PackageConsumer.ps1 @@ -103,8 +103,31 @@ if (piece != new ChessPiece(PieceColor.White, PieceValue.Pawn)) throw new InvalidOperationException("The white pawn was not found on e4."); } +Span fen = stackalloc char[128]; +if (!game.TryFormatFen(fen, out int fenWritten) || !fen[..fenWritten].SequenceEqual(game.ToFen())) +{ + throw new InvalidOperationException("The package consumer could not format FEN into a span."); +} + +if (game.PlyCount != 1 || game.GetFenAtPly(0) != ChessGame.StartingFen + || game.GetFenAtPly(1) != game.ToFen() + || !game.TryFormatFenAtPly(0, fen, out fenWritten) + || !fen[..fenWritten].SequenceEqual(ChessGame.StartingFen)) +{ + throw new InvalidOperationException("The package consumer could not read historical FEN."); +} + +var history = game.History; + game.UndoMove(); +if (game.PlyCount != 0 || game.GetFenAtPly(0) != ChessGame.StartingFen + || history.Count != 1 || history[0].FenAfter == game.ToFen() + || game.TryFormatFenAtPly(1, fen, out fenWritten) || fenWritten != 0) +{ + throw new InvalidOperationException("Historical FEN or snapshot ownership failed after undo."); +} + if (game.FormatSan(move) != "e4" || game.ParseSan("e4") != move) { throw new InvalidOperationException("The package consumer could not format and parse SAN."); @@ -125,6 +148,24 @@ if (game.MakeSanMove("e4+") != MoveResult.None || game.MakeSanMove("e4") == Move } game.MakeSanMove("e4"); +string recordJson = GameRecord.Serialize(game.ExportRecord()); +var restored = ChessGame.FromRecordJson(recordJson); +if (restored.ToFen() != game.ToFen() || restored.PlyCount != 1 + || !restored.UndoMove() || restored.ToFen() != ChessGame.StartingFen + || game.PlyCount != 1) +{ + throw new InvalidOperationException("The package consumer could not restore record JSON with independent undo."); +} + +try +{ + ChessGame.FromRecordJson(recordJson.Replace("e2e4", "e2e5")); + throw new InvalidOperationException("Invalid record JSON did not throw."); +} +catch (System.Text.Json.JsonException exception) when (exception.InnerException is FormatException) +{ +} + var document = PgnGame.FromGame(game, new Dictionary { ["Event"] = "Package smoke test" }, PgnResult.WhiteWin); using var writer = new StringWriter(); diff --git a/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs index 05f2f5f..7b47ca6 100644 --- a/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs +++ b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs @@ -34,6 +34,43 @@ internal static void Run() Measure("GameApi", scenario, "Deserialize", benchmark.Deserialize); } + foreach (int plies in new[] { 0, 20, 120 }) + { + var benchmark = new RecordJsonBenchmarks { MoveCount = plies }; + benchmark.Setup(); + string scenario = plies.ToString(CultureInfo.InvariantCulture); + Measure("RecordJson", scenario, "DeserializeThenRestore", benchmark.DeserializeThenRestore); + Measure("RecordJson", scenario, "RestoreDirectly", benchmark.RestoreDirectly); + Measure("RecordJson", scenario, "DeserializeThenMoveAndSave", benchmark.DeserializeThenMoveAndSave); + Measure("RecordJson", scenario, "RestoreThenMoveAndSave", benchmark.RestoreThenMoveAndSave); + } + + foreach (string scenario in new[] { "Start", "Kiwipete", "EnPassant", "LargeCounters" }) + { + var benchmark = new FenBenchmarks { Scenario = scenario }; + benchmark.Setup(); + Measure("FEN", scenario, "FormatString", benchmark.FormatString); + Measure("FEN", scenario, "FormatSpan", benchmark.FormatSpan); + Measure("FEN", scenario, "FormatPositionFields", benchmark.FormatPositionFields); + } + + foreach (int plies in new[] { 0, 20, 120 }) + { + var benchmark = new HistoryBenchmarks { MoveCount = plies }; + benchmark.Setup(); + string scenario = plies.ToString(CultureInfo.InvariantCulture); + Measure("History", scenario, "ReplayWithoutFen", benchmark.ReplayWithoutFen); + Measure("History", scenario, "ReplayWithFen", benchmark.ReplayWithFen); + Measure("History", scenario, "ReplayThenHistory", benchmark.ReplayThenHistory); + Measure("History", scenario, "ReplayWithHistoryEachMove", benchmark.ReplayWithHistoryEachMove); + Measure("History", scenario, "HistorySnapshot", benchmark.HistorySnapshot); + Measure("History", scenario, "Clone", benchmark.Clone); + Measure("History", scenario, "MakeAndUndo", benchmark.MakeAndUndo); + Measure("History", scenario, "MakeReadFenAndUndo", benchmark.MakeReadFenAndUndo); + Measure("History", scenario, "HistoricalFenString", benchmark.HistoricalFenString); + Measure("History", scenario, "HistoricalFenSpan", benchmark.HistoricalFenSpan); + } + foreach (string scenario in new[] { "Opening", "Ambiguous", "Mate" }) { var benchmark = new SanBenchmarks { Scenario = scenario }; diff --git a/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs new file mode 100644 index 0000000..f5a1bb6 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs @@ -0,0 +1,51 @@ +using BenchmarkDotNet.Attributes; +using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class FenBenchmarks +{ + private Position position; + private ChessGame game = null!; + private char[] destination = null!; + + [Params("Start", "Kiwipete", "EnPassant", "LargeCounters")] + public string Scenario { get; set; } = "Start"; + + [GlobalSetup] + public void Setup() + { + string fen = Scenario switch + { + "Start" => ChessGame.StartingFen, + "Kiwipete" => "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "EnPassant" => "7k/8/8/3pP3/8/8/8/7K w - d6 0 1", + "LargeCounters" => $"7k/8/8/8/8/8/8/7K b - - {new string('9', 512)} 1{new string('0', 512)}", + _ => throw new InvalidOperationException("Unknown FEN benchmark scenario.") + }; + game = ChessGame.FromFen(fen); + position = game.Position; + destination = new char[fen.Length]; + if (FenStrings.FormatUnchecked(position) != fen) + { + throw new InvalidOperationException("Invalid FEN benchmark fixture."); + } + + if (!game.TryFormatFen(destination, out int written) || !destination.AsSpan(0, written).SequenceEqual(fen)) + { + throw new InvalidOperationException("Invalid span FEN benchmark fixture."); + } + } + + [Benchmark] + public string FormatString() => FenStrings.FormatUnchecked(position); + + [Benchmark] + public int FormatSpan() => game.TryFormatFen(destination, out int written) + ? written : throw new InvalidOperationException("The FEN buffer must fit the fixture."); + + [Benchmark] + public string FormatPositionFields() => FenStrings.FormatPositionFields(position); +} diff --git a/src/ChessRealms.Engine.Benchmark/HistoryBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/HistoryBenchmarks.cs new file mode 100644 index 0000000..e485512 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/HistoryBenchmarks.cs @@ -0,0 +1,103 @@ +using BenchmarkDotNet.Attributes; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class HistoryBenchmarks +{ + private CoordinateMove[] line = null!; + private ChessGame game = null!; + private CoordinateMove nextMove; + private readonly char[] fenBuffer = new char[128]; + + [Params(0, 20, 120)] + public int MoveCount { get; set; } + + [GlobalSetup] + public void Setup() + { + line = PgnBenchmarks.CreateLongLine().Take(MoveCount).ToArray(); + game = ReplayWithoutFen(); + if (game.History.Count != MoveCount || game.IsFinished) + { + throw new InvalidOperationException("The history fixture must have every requested ply and allow a next move."); + } + + nextMove = game.GetLegalMoves()[0]; + } + + [Benchmark] + public ChessGame ReplayWithoutFen() => Replay(false, false).Game; + + [Benchmark] + public (ChessGame Game, int ObservedLength) ReplayWithFen() => Replay(true, false); + + [Benchmark] + public IReadOnlyList ReplayThenHistory() => ReplayWithoutFen().History; + + [Benchmark] + public (ChessGame Game, int ObservedLength) ReplayWithHistoryEachMove() => Replay(false, true); + + [Benchmark] + public IReadOnlyList HistorySnapshot() => game.History; + + [Benchmark] + public ChessGame Clone() => game.Clone(); + + [Benchmark] + public string HistoricalFenString() => game.GetFenAtPly(MoveCount / 2); + + [Benchmark] + public int HistoricalFenSpan() => game.TryFormatFenAtPly(MoveCount / 2, fenBuffer, out int written) + ? written : throw new InvalidOperationException("The FEN buffer must fit the fixture."); + + [Benchmark] + public MoveResult MakeAndUndo() + { + MoveResult result = game.MakeMove(nextMove); + if (result == MoveResult.None || !game.UndoMove()) + { + throw new InvalidOperationException("The benchmark must return to the original position."); + } + + return result; + } + + [Benchmark] + public string MakeReadFenAndUndo() + { + MoveResult result = game.MakeMove(nextMove); + string fen = game.ToFen(); + if (result == MoveResult.None || !game.UndoMove()) + { + throw new InvalidOperationException("The benchmark must return to the original position."); + } + + return fen; + } + + private (ChessGame Game, int ObservedLength) Replay(bool readFen, bool readHistory) + { + var replayed = new ChessGame(); + int observedLength = 0; + foreach (var move in line) + { + if (replayed.MakeMove(move) == MoveResult.None) + { + throw new InvalidOperationException("Invalid history benchmark move."); + } + + if (readFen) + { + observedLength += replayed.ToFen().Length; + } + + if (readHistory) + { + observedLength += replayed.History.Count; + } + } + + return (replayed, observedLength); + } +} diff --git a/src/ChessRealms.Engine.Benchmark/HistoryMemoryProbe.cs b/src/ChessRealms.Engine.Benchmark/HistoryMemoryProbe.cs new file mode 100644 index 0000000..b495bd7 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/HistoryMemoryProbe.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace ChessRealms.Engine.Benchmark; + +// An approximate retained-heap comparison, separate from per-operation allocation measurements. +// Keep the same fixture, game count and runtime when comparing revisions. +internal static class HistoryMemoryProbe +{ + internal static void Run() + { + var line = PgnBenchmarks.CreateLongLine(); + WarmUp(line); + Console.WriteLine("Plies,Observation,RetainedBytesPerGame"); + foreach (int plies in new[] { 0, 20, 120 }) + { + foreach (string observation in new[] { "None", "CurrentFen", "History" }) + { + Measure(line, plies, observation); + } + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void WarmUp(CoordinateMove[] line) + { + var game = CreateGame(line, line.Length, "History"); + GC.KeepAlive(game); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Measure(CoordinateMove[] line, int plies, string observation) + { + const int samples = 256; + var games = new ChessGame[samples]; + long before = GC.GetTotalMemory(forceFullCollection: true); + for (int i = 0; i < samples; i++) + { + games[i] = CreateGame(line, plies, observation); + } + + long bytes = GC.GetTotalMemory(forceFullCollection: true) - before; + GC.KeepAlive(games); + Console.WriteLine($"{plies},{observation},{(bytes / (double)samples).ToString(CultureInfo.InvariantCulture)}"); + } + + private static ChessGame CreateGame(CoordinateMove[] line, int plies, string observation) + { + var game = new ChessGame(); + for (int i = 0; i < plies; i++) + { + if (game.MakeMove(line[i]) == MoveResult.None) + { + throw new InvalidOperationException("Invalid retained-memory fixture."); + } + } + + if (observation == "CurrentFen") + { + _ = game.ToFen(); + } + else if (observation == "History") + { + // The returned snapshot is not retained; measure the game after history was read. + _ = game.History; + } + + return game; + } +} diff --git a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs index dc3dc49..7757a44 100644 --- a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs @@ -33,7 +33,7 @@ public void Setup() } } - private static CoordinateMove[] CreateLongLine() + internal static CoordinateMove[] CreateLongLine() { // Use the same 120-ply fixture prefix for every size. Reject short games // during setup instead of accidentally measuring fewer plies than advertised. diff --git a/src/ChessRealms.Engine.Benchmark/Program.cs b/src/ChessRealms.Engine.Benchmark/Program.cs index 16614d6..10fd752 100644 --- a/src/ChessRealms.Engine.Benchmark/Program.cs +++ b/src/ChessRealms.Engine.Benchmark/Program.cs @@ -10,6 +10,12 @@ return; } +if (args is ["--history-memory-probe"]) +{ + HistoryMemoryProbe.Run(); + return; +} + Console.WriteLine("ChessRealms benchmarks"); Console.WriteLine(); diff --git a/src/ChessRealms.Engine.Benchmark/RecordJsonBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/RecordJsonBenchmarks.cs new file mode 100644 index 0000000..0b1bcbf --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/RecordJsonBenchmarks.cs @@ -0,0 +1,69 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class RecordJsonBenchmarks +{ + private string json = null!; + private CoordinateMove nextMove; + + [Params(0, 20, 120)] + public int MoveCount { get; set; } + + [GlobalSetup] + public void Setup() + { + var game = new ChessGame(); + foreach (var move in PgnBenchmarks.CreateLongLine().Take(MoveCount)) + { + if (game.MakeMove(move) == MoveResult.None) + { + throw new InvalidOperationException("The JSON fixture must contain every requested ply."); + } + } + + if (game.PlyCount != MoveCount || game.IsFinished) + { + throw new InvalidOperationException("The JSON fixture must allow a next move."); + } + + nextMove = game.GetLegalMoves()[0]; + json = GameRecord.Serialize(game.ExportRecord()); + if (GameRecord.Serialize(DeserializeThenRestore().ExportRecord()) != json + || GameRecord.Serialize(RestoreDirectly().ExportRecord()) != json + || DeserializeThenMoveAndSave() != RestoreThenMoveAndSave()) + { + throw new InvalidOperationException("Both JSON paths must produce the same game record."); + } + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Restore")] + public ChessGame DeserializeThenRestore() => ChessGame.FromRecord(GameRecord.Deserialize(json)); + + [Benchmark] + [BenchmarkCategory("Restore")] + public ChessGame RestoreDirectly() => ChessGame.FromRecordJson(json); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("MoveAndSave")] + public string DeserializeThenMoveAndSave() => MoveAndSave(DeserializeThenRestore()); + + [Benchmark] + [BenchmarkCategory("MoveAndSave")] + public string RestoreThenMoveAndSave() => MoveAndSave(RestoreDirectly()); + + private string MoveAndSave(ChessGame game) + { + if (game.MakeMove(nextMove) == MoveResult.None) + { + throw new InvalidOperationException("The restored game must accept the next move."); + } + + return GameRecord.Serialize(game.ExportRecord()); + } +} diff --git a/src/ChessRealms.Engine.Tests/HistoryFenTests.cs b/src/ChessRealms.Engine.Tests/HistoryFenTests.cs new file mode 100644 index 0000000..4592d9a --- /dev/null +++ b/src/ChessRealms.Engine.Tests/HistoryFenTests.cs @@ -0,0 +1,251 @@ +using ChessRealms.Engine.Parsing; + +namespace ChessRealms.Engine.Tests; + +internal class HistoryFenTests +{ + private const string RepetitionLine = "g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1 f6g8"; + + [TestCase(ChessGame.StartingFen, "")] + [TestCase(ChessGame.StartingFen, "e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5c6")] + [TestCase(ChessGame.StartingFen, "e2e4 a7a6 e4e5 d7d5 e5d6")] + [TestCase("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "e1g1 e8c8")] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8q")] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8n")] + [TestCase("4k3/8/8/8/8/8/8/R3K3 b - - 7 999999999999999999999", "e8e7 a1a2")] + [TestCase(ChessGame.StartingFen, "f2f3 e7e5 g2g4 d8h4")] + [TestCase(ChessGame.StartingFen, RepetitionLine)] + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 149 80", "a1a2")] + public void DeferredHistoryMatchesEveryPositionAndMoveFlag(string initialFen, string line) + { + var game = ChessGame.FromFen(initialFen); + var moves = ParseLine(line); + var fens = new List { FenStrings.Format(game.Position) }; + var results = new List(); + foreach (var move in moves) + { + var result = game.MakeMove(move); + Assert.That(result, Is.Not.EqualTo(MoveResult.None)); + results.Add(result); + // Build expectations without querying the game's lazy FEN or history APIs. + fens.Add(FenStrings.Format(game.Position)); + } + + var clone = game.Clone(); + // Exercise materializing a whole snapshot before any individual FEN queries. + _ = clone.History; + AssertLine(clone, fens, moves, results); + AssertLine(game, fens, moves, results); + var snapshot = game.History; + var expectedSnapshot = snapshot.ToArray(); + for (int count = moves.Length - 1; count >= 0; count--) + { + Assert.That(game.UndoMove(), Is.True); + AssertLine(game, fens.Take(count + 1).ToArray(), moves[..count], results.Take(count).ToArray()); + Assert.That(snapshot, Is.EqualTo(expectedSnapshot)); + } + + Assert.That(game.UndoMove(), Is.False); + Assert.That(game.PlyCount, Is.Zero); + AssertLine(clone, fens, moves, results); + } + + [TestCase(false)] + [TestCase(true)] + public void SnapshotsAndColdOrWarmClonesSurviveUndoAndDifferentContinuations(bool readBeforeClone) + { + var game = new ChessGame(); + Play(game, "e2e4 e7e5 g1f3 b8c6"); + if (readBeforeClone) + { + _ = game.History; + } + + var clone = game.Clone(); + var snapshot = game.History; + var expectedSnapshot = snapshot.ToArray(); + string unchangedPrefix = game.GetFenAtPly(2); + + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.ToFen(), Is.SameAs(unchangedPrefix)); + Play(game, "f1c4"); + Assert.That(game.PlyCount, Is.EqualTo(3)); + Assert.That(game.ToFen(), Is.EqualTo(FenStrings.Format(game.Position))); + Assert.That(game.GetFenAtPly(3), Is.Not.EqualTo(snapshot[2].FenAfter)); + Assert.Throws(() => game.GetFenAtPly(4)); + + Assert.That(clone.UndoMove(), Is.True); + Play(clone, "d7d6"); + Assert.That(clone.PlyCount, Is.EqualTo(4)); + Assert.That(clone.ToFen(), Is.EqualTo(FenStrings.Format(clone.Position))); + Assert.That(clone.GetFenAtPly(3), Is.EqualTo(snapshot[2].FenAfter)); + Assert.That(clone.GetFenAtPly(4), Is.Not.EqualTo(snapshot[3].FenAfter)); + Assert.That(game.GetFenAtPly(2), Is.SameAs(unchangedPrefix)); + Assert.That(snapshot, Is.EqualTo(expectedSnapshot)); + Assert.Throws(() => ((IList)snapshot)[0] = default); + } + + [TestCase(false)] + [TestCase(true)] + public void HistoricalFenQueriesAndRejectedMovesPreserveDrawClaims(bool intended) + { + var game = new ChessGame(); + Play(game, string.Join(' ', ParseLine(RepetitionLine).Take(intended ? 7 : 8))); + CoordinateMove? intendedMove = intended ? CoordinateMove.Parse("f6g8") : null; + Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition, intendedMove), Is.True); + var before = game.Clone(); + string current = game.ToFen(); + + Span buffer = stackalloc char[128]; + for (int ply = 0; ply <= game.PlyCount; ply++) + { + Assert.That(game.TryFormatFenAtPly(ply, buffer, out int written), Is.True); + Assert.That(new string(buffer[..written]), Is.EqualTo(game.GetFenAtPly(ply))); + } + + Assert.That(game.MakeMove(CoordinateMove.Parse("g1f3")), Is.EqualTo(MoveResult.None)); + Assert.That(game.MakeSanMove("Nf3"), Is.EqualTo(MoveResult.None)); + Assert.Multiple(() => + { + Assert.That(game.ToFen(), Is.SameAs(current)); + Assert.That(game.Position, Is.EqualTo(before.Position)); + Assert.That(game.Outcome, Is.EqualTo(before.Outcome)); + Assert.That(game.DrawIntendedMove, Is.EqualTo(before.DrawIntendedMove)); + Assert.That(game.RepetitionCount, Is.EqualTo(before.RepetitionCount)); + Assert.That(game.History, Is.EqualTo(before.History)); + }); + Assert.That(game.UndoMove(), Is.True); + Assert.That(before.UndoMove(), Is.True); + Assert.That(game.ToFen(), Is.EqualTo(before.ToFen())); + Assert.That(game.DrawIntendedMove, Is.Null); + } + + [Test] + public void ClaimWithoutHistoryAndFailedUndoKeepInitialPosition() + { + var game = ChessGame.FromFen("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + string initial = game.GetFenAtPly(0); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1a2")), Is.True); + var outcome = game.Outcome; + Assert.That(game.UndoMove(), Is.False); + Assert.That(game.PlyCount, Is.Zero); + Assert.That(game.GetFenAtPly(0), Is.SameAs(initial)); + Assert.That(game.History, Is.Empty); + Assert.That(game.Outcome, Is.EqualTo(outcome)); + } + + [Test] + public void RejectedMoveBeforeGameEndsDoesNotAddOrInvalidateHistory() + { + var game = new ChessGame(); + Play(game, "e2e4"); + string current = game.ToFen(); + var snapshot = game.History; + Assert.That(game.MakeMove(CoordinateMove.Parse("e2e5")), Is.EqualTo(MoveResult.None)); + Assert.That(game.MakeSanMove("e5+"), Is.EqualTo(MoveResult.None)); + Assert.That(game.PlyCount, Is.EqualTo(1)); + Assert.That(game.ToFen(), Is.SameAs(current)); + Assert.That(game.History, Is.EqualTo(snapshot)); + } + + [TestCase(-1)] + [TestCase(2)] + [TestCase(int.MinValue)] + [TestCase(int.MaxValue)] + public void OutOfRangePlyHasExplicitFailureAndLeavesBufferUnchanged(int ply) + { + var game = new ChessGame(); + Play(game, "e2e4"); + var exception = Assert.Throws(() => game.GetFenAtPly(ply)); + Assert.That(exception!.ParamName, Is.EqualTo("ply")); + char[] buffer = Enumerable.Repeat('?', 128).ToArray(); + Assert.That(game.TryFormatFenAtPly(ply, buffer.AsSpan(2, 100), out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer, Is.All.EqualTo('?')); + Assert.That(game.TryFormatFenAtPly(ply, Span.Empty, out written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(game.PlyCount, Is.EqualTo(1)); + } + + [Test] + public void LargeHistoricalCountersUseTheCorrectStateAndRespectSpanBoundaries() + { + string fullmove = new('9', 4096); + var game = ChessGame.FromFen($"4k3/8/8/8/8/8/8/R3K3 b - - 7 {fullmove}"); + var expected = new List { FenStrings.Format(game.Position) }; + foreach (var move in ParseLine("e8e7 a1a2")) + { + Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); + expected.Add(FenStrings.Format(game.Position)); + } + + for (int ply = 0; ply < expected.Count; ply++) + { + AssertSpanAtPly(game, ply, expected[ply]); + Assert.That(game.GetFenAtPly(ply), Is.EqualTo(expected[ply])); + } + } + + [Test] + public void PublicHistoryEntriesKeepConstructorDeconstructionAndValueSemantics() + { + var game = new ChessGame(); + var move = CoordinateMove.Parse("e2e4"); + var flags = game.MakeMove(move); + var entry = game.History[0]; + var expected = new MoveHistoryEntry(move, ChessGame.StartingFen, game.ToFen(), flags); + Assert.That(entry, Is.EqualTo(expected)); + Assert.That(entry.GetHashCode(), Is.EqualTo(expected.GetHashCode())); + var (actualMove, before, after, result) = entry; + Assert.That((actualMove, before, after, result), Is.EqualTo((move, ChessGame.StartingFen, game.ToFen(), flags))); + var editedCopy = entry with { FenAfter = "consumer text" }; + Assert.That(editedCopy, Is.Not.EqualTo(entry)); + Assert.That(game.History[0], Is.EqualTo(expected)); + } + + private static void AssertLine(ChessGame game, IReadOnlyList fens, + IReadOnlyList moves, IReadOnlyList results) + { + Assert.That(game.PlyCount, Is.EqualTo(moves.Count)); + for (int ply = moves.Count; ply >= 0; ply--) + { + AssertSpanAtPly(game, ply, fens[ply]); + string fen = game.GetFenAtPly(ply); + Assert.That(fen, Is.EqualTo(fens[ply])); + Assert.That(game.GetFenAtPly(ply), Is.SameAs(fen)); + } + + Assert.That(game.ToFen(), Is.SameAs(game.GetFenAtPly(game.PlyCount))); + var snapshot = game.History; + Assert.That(snapshot.Count, Is.EqualTo(moves.Count)); + for (int i = 0; i < moves.Count; i++) + { + Assert.That(snapshot[i], Is.EqualTo(new MoveHistoryEntry(moves[i], fens[i], fens[i + 1], results[i]))); + } + } + + private static void AssertSpanAtPly(ChessGame game, int ply, string expected) + { + char[] buffer = Enumerable.Repeat('?', expected.Length + 4).ToArray(); + Assert.That(game.TryFormatFenAtPly(ply, buffer.AsSpan(1, expected.Length - 1), out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer, Is.All.EqualTo('?')); + Assert.That(game.TryFormatFenAtPly(ply, buffer.AsSpan(1, expected.Length), out written), Is.True); + Assert.That(written, Is.EqualTo(expected.Length)); + Assert.That(new string(buffer, 1, written), Is.EqualTo(expected)); + Assert.That(buffer[0], Is.EqualTo('?')); + Assert.That(buffer[(written + 1)..], Is.All.EqualTo('?')); + } + + private static CoordinateMove[] ParseLine(string line) + => line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(text => CoordinateMove.Parse(text)).ToArray(); + + private static void Play(ChessGame game, string line) + { + foreach (var move in ParseLine(line)) + { + Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); + } + } +} diff --git a/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs b/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs index f8a3ce6..ccc4d76 100644 --- a/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs +++ b/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs @@ -3,8 +3,17 @@ namespace ChessRealms.Engine.Tests.Json; +[TestFixture(false)] +[TestFixture(true)] internal class GameRecordJsonTests { + private readonly bool restoreDirectly; + + public GameRecordJsonTests(bool restoreDirectly) + { + this.restoreDirectly = restoreDirectly; + } + private const string RepetitionCycle = "g1f3 g8f6 f3g1 f6g8"; private const string RookEnding = "4k3/8/8/8/8/8/8/R3K3 w - - "; @@ -21,7 +30,7 @@ internal class GameRecordJsonTests [Test] public void VersionOne_ImportsFixedFixtureAndExportsExpectedFields() { - var record = GameRecord.Deserialize(VersionOneRecord); + var record = DeserializeRecord(VersionOneRecord); using var document = JsonDocument.Parse(GameRecord.Serialize(record)); var root = document.RootElement; @@ -62,8 +71,9 @@ public void JsonRoundTrip_PreservesGameHistoryOutcomeAndUndo(string fen, string var original = ChessGame.FromFen(fen); Play(original, moves); - var record = GameRecord.Deserialize(GameRecord.Serialize(original.ExportRecord())); - var restored = ChessGame.FromRecord(record); + string json = GameRecord.Serialize(original.ExportRecord()); + var record = DeserializeRecord(json); + var restored = RestoreGame(json); Assert.That(record.InitialFen, Is.EqualTo(fen)); AssertEquivalent(original, restored); @@ -96,9 +106,41 @@ public void JsonRoundTrip_PreservesClaimWithoutPlayingIntendedMove( Assert.That(claimJson.GetProperty("reason").GetString(), Is.EqualTo(reasonText)); Assert.That(claimJson.GetProperty("intendedMove").GetString(), Is.EqualTo(intendedMoveText)); - var record = GameRecord.Deserialize(json); + var record = DeserializeRecord(json); Assert.That(record.DrawClaim, Is.EqualTo(new DrawClaimRecord(reason, intendedMove))); - AssertEquivalent(original, ChessGame.FromRecord(record)); + var restored = RestoreGame(json); + AssertEquivalent(original, restored); + while (original.UndoMove()) + { + Assert.That(restored.UndoMove(), Is.True); + AssertEquivalent(original, restored); + } + + Assert.That(restored.UndoMove(), Is.False); + } + + [Test] + public void Restore_ContinuesRepetitionWithIndependentGameOwnership() + { + var original = new ChessGame(); + Play(original, RepetitionCycle); + string json = GameRecord.Serialize(original.ExportRecord()); + var restored = RestoreGame(json); + var secondRestore = RestoreGame(json); + + Assert.That(restored, Is.Not.SameAs(secondRestore)); + Play(restored, RepetitionCycle); + Assert.That(restored.RepetitionCount, Is.EqualTo(3)); + Assert.That(restored.AvailableDrawClaims, Is.EqualTo(DrawClaim.ThreefoldRepetition)); + AssertEquivalent(original, secondRestore); + + Assert.That(restored.UndoMove(), Is.True); + Assert.That(restored.GetAvailableDrawClaims(CoordinateMove.Parse("f6g8")), + Is.EqualTo(DrawClaim.ThreefoldRepetition)); + Play(restored, "f6g8 " + RepetitionCycle + " " + RepetitionCycle); + Assert.That(restored.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + Assert.That(GameRecord.Serialize(secondRestore.ExportRecord()), Is.EqualTo(json)); + AssertEquivalent(original, secondRestore); } [Test] @@ -108,7 +150,7 @@ public void Deserialize_AllowsReorderedFieldsAndOmittedOptionalClaim() { "moves": [], "initialFen": "{{ChessGame.StartingFen}}", "schemaVersion": 1 } """; - var record = GameRecord.Deserialize(json); + var record = DeserializeRecord(json); Assert.That(record.Moves, Is.Empty); Assert.That(record.DrawClaim, Is.Null); @@ -126,7 +168,7 @@ public void Deserialize_AllowsClaimWithoutOptionalIntendedMove() } """; - var record = GameRecord.Deserialize(json); + var record = DeserializeRecord(json); Assert.That(record.DrawClaim, Is.EqualTo(new DrawClaimRecord(DrawClaim.FiftyMoveRule))); } @@ -140,7 +182,7 @@ public void Deserialize_AllowsClaimWithoutOptionalIntendedMove() [TestCase(VersionOneRecord + " {}")] public void Deserialize_RejectsInvalidRoot(string json) { - Assert.Throws(() => GameRecord.Deserialize(json)); + Assert.Throws(() => DeserializeRecord(json)); } [TestCase("schemaVersion")] @@ -151,7 +193,7 @@ public void Deserialize_RejectsMissingRequiredFields(string field) var document = JsonNode.Parse(VersionOneRecord)!.AsObject(); document.Remove(field); - Assert.Throws(() => GameRecord.Deserialize(document.ToJsonString())); + Assert.Throws(() => DeserializeRecord(document.ToJsonString())); } [TestCase("schemaVersion", "null")] @@ -164,6 +206,7 @@ public void Deserialize_RejectsMissingRequiredFields(string field) [TestCase("initialFen", "null")] [TestCase("initialFen", "42")] [TestCase("initialFen", "\"not a FEN\"")] + [TestCase("initialFen", "\"8/8/8/8/8/8/8/8 w - - 0 1\"")] [TestCase("moves", "null")] [TestCase("moves", "\"e2e4\"")] [TestCase("moves", "[null]")] @@ -189,7 +232,7 @@ public void Deserialize_RejectsInvalidFields(string field, string value) var document = JsonNode.Parse(VersionOneRecord)!; document[field] = JsonNode.Parse(value); - Assert.Throws(() => GameRecord.Deserialize(document.ToJsonString())); + Assert.Throws(() => DeserializeRecord(document.ToJsonString())); } [TestCase("\"reason\":\"fiftyMoveRule\",\"extra\":true")] @@ -206,7 +249,7 @@ public void Deserialize_RejectsUnknownOrDuplicateClaimFields(string claimFields) } """; - Assert.Throws(() => GameRecord.Deserialize(json)); + Assert.Throws(() => DeserializeRecord(json)); } [TestCase("schemaVersion", "1")] @@ -218,7 +261,7 @@ public void Deserialize_RejectsDuplicateRootFields(string field, string value) { string json = VersionOneRecord.Insert(1, $"\"{field}\":{value},"); - Assert.Throws(() => GameRecord.Deserialize(json)); + Assert.Throws(() => DeserializeRecord(json)); } [TestCase("[\"e2e4\",\"d2d4\"]", 1)] @@ -228,7 +271,7 @@ public void Deserialize_ReportsUnplayableMoveIndex(string moves, int index) var document = JsonNode.Parse(VersionOneRecord)!; document["moves"] = JsonNode.Parse(moves); - var error = Assert.Throws(() => GameRecord.Deserialize(document.ToJsonString())); + var error = Assert.Throws(() => DeserializeRecord(document.ToJsonString())); Assert.That(error!.Message, Does.Contain($"index {index}")); Assert.That(error.InnerException, Is.TypeOf()); @@ -238,9 +281,20 @@ public void Deserialize_ReportsUnplayableMoveIndex(string moves, int index) public void Methods_RejectNullArguments() { Assert.Throws(() => GameRecord.Serialize(null!)); - Assert.Throws(() => GameRecord.Deserialize(null!)); + var error = Assert.Throws(() => DeserializeRecord(null!)); + Assert.That(error!.ParamName, Is.EqualTo("json")); } + // Apply the same JSON contract cases to both public entry points. Game tests + // use the directly returned instance, so export/replay cannot hide missing state. + private GameRecord DeserializeRecord(string json) => restoreDirectly + ? ChessGame.FromRecordJson(json).ExportRecord() + : GameRecord.Deserialize(json); + + private ChessGame RestoreGame(string json) => restoreDirectly + ? ChessGame.FromRecordJson(json) + : ChessGame.FromRecord(GameRecord.Deserialize(json)); + private static void Play(ChessGame game, string moves) { foreach (string move in moves.Split(' ', StringSplitOptions.RemoveEmptyEntries)) diff --git a/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs b/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs new file mode 100644 index 0000000..f326311 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs @@ -0,0 +1,147 @@ +using ChessRealms.Engine.Parsing; +using System.Globalization; +using System.Numerics; + +namespace ChessRealms.Engine.Tests.Parsing; + +internal class FenFormattingTests +{ + private static IEnumerable CanonicalFens() + { + yield return ChessGame.StartingFen; + yield return "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; + yield return "7k/8/8/3pP3/8/8/8/7K w - d6 0 1"; + yield return "7k/8/8/8/3Pp3/8/8/7K b - d3 0 1"; + yield return "7k/8/8/p7/8/8/8/7K w - a6 0 1"; + yield return "7k/8/8/8/7P/8/8/7K b - h3 0 1"; + yield return "k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1"; + yield return "k7/1Q6/2K5/8/8/8/8/8 b - - 0 1"; + yield return "k7/8/1QK5/8/8/8/8/8 b - - 0 1"; + yield return "7k/8/8/8/8/8/8/7K w - - 0 1"; + yield return "7k/8/8/8/8/8/8/7K b - - 2147483648 9223372036854775808"; + + for (int flags = 0; flags < 16; flags++) + { + string rights = new("KQkq".Where((_, bit) => (flags & (1 << bit)) != 0).ToArray()); + yield return $"r3k2r/8/8/8/8/8/8/R3K2R w {(rights.Length == 0 ? "-" : rights)} - 17 42"; + } + } + + [TestCaseSource(nameof(CanonicalFens))] + public void FormatsCanonicalFenIntoExactAndLargerSpans(string fen) + { + var game = ChessGame.FromFen(fen); + AssertFormatting(game, fen); + Assert.That(FenStrings.FormatPositionFields(game.Position), Is.EqualTo(string.Join(' ', fen.Split(' ').Take(4)))); + } + + [TestCaseSource(nameof(CanonicalFens))] + public void EveryShortDestinationRemainsUnchanged(string fen) + { + var game = ChessGame.FromFen(fen); + char[] buffer = Enumerable.Repeat('?', fen.Length + 2).ToArray(); + for (int capacity = 0; capacity < fen.Length; capacity++) + { + Assert.That(game.TryFormatFen(buffer.AsSpan(1, capacity), out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer, Is.All.EqualTo('?')); + } + } + + [TestCase(1)] + [TestCase(9)] + [TestCase(19)] + [TestCase(20)] + [TestCase(21)] + [TestCase(22)] + [TestCase(38)] + [TestCase(128)] + [TestCase(4096)] + public void ArbitraryPrecisionCountersAroundPowersOfTenAreNeverTruncated(int exponent) + { + BigInteger power = BigInteger.Pow(10, exponent); + foreach (int offset in new[] { -1, 0, 1 }) + { + string halfmove = (power + offset).ToString(CultureInfo.InvariantCulture); + string fullmove = (power - offset).ToString(CultureInfo.InvariantCulture); + string fen = $"7k/8/8/8/8/8/8/7K b - - {halfmove} {fullmove}"; + var game = ChessGame.FromFen(fen); + AssertFormatting(game, fen); + + char[] buffer = Enumerable.Repeat('?', fen.Length + 2).ToArray(); + Assert.That(game.TryFormatFen(buffer.AsSpan(1, fen.Length - 1), out int written), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer, Is.All.EqualTo('?')); + Assert.That(game.TryFormatFen(Span.Empty, out written), Is.False); + Assert.That(written, Is.Zero); + } + } + + [Test] + [SetCulture("ar-SA")] + public void FormattingIsInvariantAndNormalizesLeadingZeroCounters() + { + var game = ChessGame.FromFen("7k/8/8/8/8/8/8/7K b - - 00042 000123456789012345678901234567890"); + AssertFormatting(game, "7k/8/8/8/8/8/8/7K b - - 42 123456789012345678901234567890"); + } + + [Test] + public void UncheckedFormatterSupportsTheMaximumPositionFieldLength() + { + // Internal geometric fixtures may occupy all squares and omit kings. + string fields = $"{string.Join('/', Enumerable.Repeat("pppppppp", 8))} w KQkq h6"; + string fen = fields + " 0 1"; + Assert.That(FenStrings.TryParseSyntax(fen, out var position), Is.True); + Assert.That(FenStrings.FormatUnchecked(position), Is.EqualTo(fen)); + Assert.That(FenStrings.FormatPositionFields(position), Is.EqualTo(fields)); + Span buffer = stackalloc char[fen.Length]; + Assert.That(FenStrings.TryFormatUnchecked(position, buffer, out int written), Is.True); + Assert.That(new string(buffer[..written]), Is.EqualTo(fen)); + } + + [Test] + public void FormattingTracksMovesAndUndoWithoutChangingHistoryOrDrawClaims() + { + var game = new ChessGame(); + foreach (string text in new[] { "g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8" }) + { + Assert.That(game.MakeMove(CoordinateMove.Parse(text)), Is.Not.EqualTo(MoveResult.None)); + AssertFormatting(game, game.ToFen()); + } + + Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition), Is.True); + var before = game.Clone(); + AssertFormatting(game, game.ToFen()); + Assert.Multiple(() => + { + Assert.That(game.Position, Is.EqualTo(before.Position)); + Assert.That(game.History, Is.EqualTo(before.History)); + Assert.That(game.Outcome, Is.EqualTo(before.Outcome)); + Assert.That(game.DrawIntendedMove, Is.EqualTo(before.DrawIntendedMove)); + Assert.That(game.RepetitionCount, Is.EqualTo(before.RepetitionCount)); + }); + + while (game.UndoMove()) + { + AssertFormatting(game, game.ToFen()); + } + + AssertFormatting(game, ChessGame.StartingFen); + AssertFormatting(before, before.ToFen()); + } + + private static void AssertFormatting(ChessGame game, string expected) + { + Assert.That(FenStrings.Format(game.Position), Is.EqualTo(expected)); + Assert.That(game.ToFen(), Is.EqualTo(expected)); + char[] buffer = Enumerable.Repeat('?', expected.Length + 6).ToArray(); + foreach (int capacity in new[] { expected.Length, expected.Length + 2 }) + { + Assert.That(game.TryFormatFen(buffer.AsSpan(2, capacity), out int written), Is.True); + Assert.That(written, Is.EqualTo(expected.Length)); + Assert.That(new string(buffer, 2, written), Is.EqualTo(expected)); + Assert.That(buffer[..2], Is.All.EqualTo('?')); + Assert.That(buffer[(written + 2)..], Is.All.EqualTo('?')); + } + } +} diff --git a/src/ChessRealms.Engine/ChessGame.History.cs b/src/ChessRealms.Engine/ChessGame.History.cs new file mode 100644 index 0000000..35fcc7e --- /dev/null +++ b/src/ChessRealms.Engine/ChessGame.History.cs @@ -0,0 +1,74 @@ +using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; + +namespace ChessRealms.Engine; + +public sealed partial class ChessGame +{ + // Each state already exists in undo or position. Cache text only after a string query. + // Copying this value into a clone shares immutable strings, never mutable cache ownership. + private readonly record struct RecordedMove(CoordinateMove Move, MoveResult Result, string? CachedFen = null); + + /// Gets canonical FEN after the specified number of recorded half-moves. + /// Zero selects the initial position, including a custom starting FEN; PlyCount selects + /// the current position. The index follows the current line after undo. Formats only the selected + /// position and caches its string. Does not change the game's position, history or outcome. + /// The ply is negative or greater than PlyCount. + public string GetFenAtPly(int ply) + { + if ((uint)ply > (uint)history.Count) + { + throw new ArgumentOutOfRangeException(nameof(ply), ply, "The ply must be between zero and PlyCount."); + } + + return ply == 0 ? initialFen : GetOrCreateFenAfter(ply - 1); + } + + /// Writes canonical FEN after the specified number of recorded half-moves without creating a string. + /// Zero selects the initial position; PlyCount selects the current position. Returns false + /// with zero characters written and leaves the destination unchanged for an out-of-range ply or + /// insufficient space. Does not populate string caches or change the game. Move counters have + /// arbitrary precision, so there is no fixed maximum FEN length. + public bool TryFormatFenAtPly(int ply, Span destination, out int charsWritten) + { + charsWritten = 0; + return (uint)ply <= (uint)history.Count + && FenStrings.TryFormatUnchecked(GetPositionAtPly(ply), destination, out charsWritten); + } + + // The caller has checked the range. undo[i] is the position before move i. + private Position GetPositionAtPly(int ply) => ply == history.Count ? position : undo[ply]; + + private string GetOrCreateFenAfter(int moveIndex) + { + var entry = history[moveIndex]; + if (entry.CachedFen is { } fen) + { + return fen; + } + + fen = FenStrings.FormatUnchecked(GetPositionAtPly(moveIndex + 1)); + history[moveIndex] = entry with { CachedFen = fen }; + return fen; + } + + private IReadOnlyList CopyHistory() + { + if (history.Count == 0) + { + return Array.AsReadOnly(Array.Empty()); + } + + var snapshot = new MoveHistoryEntry[history.Count]; + string before = initialFen; + for (int i = 0; i < snapshot.Length; i++) + { + var entry = history[i]; + string after = entry.CachedFen ?? GetOrCreateFenAfter(i); + snapshot[i] = new MoveHistoryEntry(entry.Move, before, after, entry.Result); + before = after; + } + + return Array.AsReadOnly(snapshot); + } +} diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index fd2aa78..5a289df 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -2,6 +2,7 @@ using ChessRealms.Engine.Core.Math; using ChessRealms.Engine.Core.Movements; using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Json; using ChessRealms.Engine.Parsing; using System.Diagnostics.CodeAnalysis; using System.Numerics; @@ -16,7 +17,7 @@ public sealed partial class ChessGame private Position position; private readonly string initialFen; - private readonly List history = []; + private readonly List history = []; private readonly List undo = []; private readonly List keys = []; private readonly Dictionary repetitions = new(StringComparer.Ordinal); @@ -48,7 +49,12 @@ public GameState State } public int RepetitionCount => repetitions[keys[^1]]; - public IReadOnlyList History => Array.AsReadOnly(history.ToArray()); + + /// The number of recorded half-moves since the initial position, excluding undone moves. + public int PlyCount => history.Count; + + /// An independent read-only snapshot of moves, their before/after FEN and move flags. + public IReadOnlyList History => CopyHistory(); public DrawClaim AvailableDrawClaims => IsFinished ? DrawClaim.None : CalculateDrawClaims(position, RepetitionCount); @@ -90,7 +96,16 @@ private ChessGame(ChessGame original) } public ChessGame Clone() => new(this); - public string ToFen() => history.Count == 0 ? initialFen : history[^1].FenAfter; + + /// Gets the current position's canonical FEN, formatting and caching it on first request. + public string ToFen() => GetFenAtPly(history.Count); + + /// Writes the current position as canonical FEN without creating a string. + /// Does not change the game. If the destination is too small, returns false with zero + /// characters written and leaves the destination unchanged. Move counters have arbitrary precision, + /// so there is no fixed maximum FEN length. Also works after the game finishes. + public bool TryFormatFen(Span destination, out int charsWritten) + => FenStrings.TryFormatUnchecked(position, destination, out charsWritten); /// Exports an independent snapshot of the starting position, current move line and draw claim. public GameRecord ExportRecord() => GameRecord.CreateFromGame(this); @@ -116,6 +131,14 @@ internal CoordinateMove[] CopyRecordedMoves() /// The initial FEN, a recorded move or the draw claim is invalid. public static ChessGame FromRecord(GameRecord gameRecord) => GameRecord.RestoreGame(gameRecord); + /// Restores an independent game from versioned game-record JSON, validating and replaying it once. + /// Accepts the format written by . Rebuilds history, + /// repetition counts, outcome and undo, including a draw claim's unplayed intended move. + /// The JSON string is null. + /// The JSON is invalid, its schema is unsupported, + /// or the game cannot be restored. Replay errors retain their original FormatException as the inner exception. + public static ChessGame FromRecordJson(string json) => GameRecordJsonSerializer.DeserializeGame(json); + /// Copies the board in a1-to-h8 order. public void CopyBoardTo(Span destination) { @@ -222,7 +245,7 @@ private MoveResult ApplyLegalMove(CoordinateMove move, int encodedMove) result |= MoveResult.Stalemate; } - var entry = new MoveHistoryEntry(move, ToFen(), FenStrings.FormatUnchecked(nextPosition), result); + var entry = new RecordedMove(move, result); undo.Add(position); history.Add(entry); keys.Add(key); diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index 04dafa9..655de27 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -24,6 +24,7 @@ public sealed class GameRecord public static string Serialize(GameRecord record) => GameRecordJsonSerializer.Serialize(record); /// Deserializes a game record and validates it by replaying its moves and draw claim. + /// Use when a playable game is needed directly. /// The JSON string is null. /// The JSON is invalid, its schema is unsupported, /// or the game cannot be restored. diff --git a/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs b/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs index ff4d401..a3d6206 100644 --- a/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs +++ b/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs @@ -30,7 +30,11 @@ internal static string Serialize(GameRecord record) return JsonSerializer.Serialize(document, Options); } - internal static GameRecord Deserialize(string json) + internal static GameRecord Deserialize(string json) => DeserializeValidated(json).Record; + + internal static ChessGame DeserializeGame(string json) => DeserializeValidated(json).Game; + + private static (GameRecord Record, ChessGame Game) DeserializeValidated(string json) { ArgumentNullException.ThrowIfNull(json); @@ -45,14 +49,12 @@ internal static GameRecord Deserialize(string json) try { // Replay is the authority for move legality and draw claims, including intended moves. - GameRecord.RestoreGame(record); + return (record, GameRecord.RestoreGame(record)); } catch (FormatException ex) { throw new JsonException(ex.Message, ex); } - - return record; } private readonly record struct GameRecordDocument diff --git a/src/ChessRealms.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs index 356151d..f4fdd32 100644 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ b/src/ChessRealms.Engine/Parsing/FenStrings.cs @@ -1,9 +1,8 @@ using ChessRealms.Engine.Core.Constants; -using ChessRealms.Engine.Core.Math; using ChessRealms.Engine.Core.Types; +using System.Buffers; using System.Globalization; using System.Numerics; -using System.Text; namespace ChessRealms.Engine.Parsing; @@ -11,6 +10,10 @@ internal static class FenStrings { internal const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + // 64 piece symbols, seven rank separators, side, castling and en passant fields. + private const int MaxPositionFieldsLength = 81; + private const int StackBufferLength = 128; + internal static bool TryParse(string? fen, out Position position) { if (TryParseSyntax(fen, out var candidate) && PositionValidation.IsValid(candidate)) @@ -186,23 +189,94 @@ internal static string Format(Position position) internal static string FormatUnchecked(Position position) { - var result = new StringBuilder(); - AppendPositionFields(result, position); - result.Append(' ').Append(position.halfMoveClock.ToString(CultureInfo.InvariantCulture)); - result.Append(' ').Append(position.fullMoveCount.ToString(CultureInfo.InvariantCulture)); - return result.ToString(); + int capacity = GetBufferLength(position); + char[]? rented = null; + Span text = capacity <= StackBufferLength + ? stackalloc char[StackBufferLength] + : (rented = ArrayPool.Shared.Rent(capacity)); + try + { + int written = WriteUnchecked(position, text); + return new string(text[..written]); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + internal static bool TryFormatUnchecked(Position position, Span destination, out int charsWritten) + { + charsWritten = 0; + int capacity = GetBufferLength(position); + char[]? rented = null; + Span text = capacity <= StackBufferLength + ? stackalloc char[StackBufferLength] + : (rented = ArrayPool.Shared.Rent(capacity)); + try + { + int written = WriteUnchecked(position, text); + if (!text[..written].TryCopyTo(destination)) + { + return false; + } + + charsWritten = written; + return true; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + private static int GetBufferLength(Position position) + => checked(MaxPositionFieldsLength + 2 + + GetCounterBufferLength(position.halfMoveClock) + + GetCounterBufferLength(position.fullMoveCount)); + + // Since 10 > 2^3, bitLength / 3 + 1 safely bounds the decimal digits. + // This integer bound also works at powers of ten without floating-point rounding. + private static int GetCounterBufferLength(BigInteger value) + => checked((int)(value.GetBitLength() / 3 + 1) + (value.Sign < 0 ? 1 : 0)); + + private static int WriteUnchecked(Position position, Span destination) + { + int length = WritePositionFields(position, destination); + destination[length++] = ' '; + length += WriteCounter(position.halfMoveClock, destination[length..]); + destination[length++] = ' '; + length += WriteCounter(position.fullMoveCount, destination[length..]); + return length; + } + + private static int WriteCounter(BigInteger value, Span destination) + { + if (!value.TryFormat(destination, out int written, provider: CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException("The internal FEN buffer is too small."); + } + + return written; } // The first four FEN fields identify a position without its move counters. internal static string FormatPositionFields(Position position) { - var result = new StringBuilder(); - AppendPositionFields(result, position); - return result.ToString(); + Span text = stackalloc char[MaxPositionFieldsLength]; + int written = WritePositionFields(position, text); + return new string(text[..written]); } - private static void AppendPositionFields(StringBuilder result, Position position) + private static int WritePositionFields(Position position, Span destination) { + int length = 0; for (int rank = 7; rank >= 0; rank--) { int emptySquares = 0; @@ -222,29 +296,31 @@ private static void AppendPositionFields(StringBuilder result, Position position if (emptySquares != 0) { - result.Append(emptySquares); + destination[length++] = (char)('0' + emptySquares); emptySquares = 0; } char symbol = "pnbrqk"[piece.Value]; - result.Append(piece.Color == Colors.White ? char.ToUpperInvariant(symbol) : symbol); + destination[length++] = piece.Color == Colors.White ? char.ToUpperInvariant(symbol) : symbol; } if (emptySquares != 0) { - result.Append(emptySquares); + destination[length++] = (char)('0' + emptySquares); } if (rank != 0) { - result.Append('/'); + destination[length++] = '/'; } } - result.Append(position.color == Colors.White ? " w " : " b "); + destination[length++] = ' '; + destination[length++] = position.color == Colors.White ? 'w' : 'b'; + destination[length++] = ' '; if (position.castlings == Castlings.None) { - result.Append('-'); + destination[length++] = '-'; } else { @@ -252,14 +328,22 @@ private static void AppendPositionFields(StringBuilder result, Position position { if ((position.castlings & (1 << i)) != 0) { - result.Append("KQkq"[i]); + destination[length++] = "KQkq"[i]; } } } - string enPassant = position.enpassant == Squares.Empty - ? "-" - : SquareOps.ToAbbreviature(position.enpassant); - result.Append(' ').Append(enPassant); + destination[length++] = ' '; + if (position.enpassant == Squares.Empty) + { + destination[length++] = '-'; + } + else + { + destination[length++] = (char)('a' + position.enpassant % 8); + destination[length++] = (char)('1' + position.enpassant / 8); + } + + return length; } } From 695029facbac0eb8c5864966910e6e6931dd15e0 Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 22 Oct 2026 22:05:04 +0300 Subject: [PATCH 2/4] perf(engine): optimize repetition tracking and PGN export --- ChessRealms.Engine.slnx | 1 + docs/game-rules-api.md | 3 + docs/pgn.md | 6 + docs/repetition.md | 80 ++++++++ .../PgnExportTests.cs | 18 ++ .../RepetitionKeyTests.cs | 192 ++++++++++++++++++ src/ChessRealms.Engine/ChessGame.San.cs | 110 ++-------- src/ChessRealms.Engine/ChessGame.cs | 106 ++-------- .../Core/Movements/LegalMoves.cs | 50 +++++ .../Core/Types/RepetitionKey.cs | 68 +++++++ src/ChessRealms.Engine/Parsing/PgnWriter.cs | 44 +++- .../Parsing/SanFormatter.cs | 95 +++++++++ 12 files changed, 583 insertions(+), 190 deletions(-) create mode 100644 docs/repetition.md create mode 100644 src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs create mode 100644 src/ChessRealms.Engine/Core/Movements/LegalMoves.cs create mode 100644 src/ChessRealms.Engine/Core/Types/RepetitionKey.cs create mode 100644 src/ChessRealms.Engine/Parsing/SanFormatter.cs diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 4f18fa5..694b2d8 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -7,6 +7,7 @@ + diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 4609781..7034af7 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -254,6 +254,9 @@ compares placement, side to move, castling rights and the en passant square only when a *legal* en passant capture exists; move counters are ignored. A pinned pawn does not make en passant available for this purpose. +The [repetition guide](repetition.md) explains position identity, counting, +undo, hash collisions and why restoring counts requires the game line. + Dead-position detection covers king versus king, king plus one bishop or knight versus king, and bishops-only positions where every bishop is on the same square color. It does not attempt general dead-position proof, including diff --git a/docs/pgn.md b/docs/pgn.md index 09b85ee..6718de5 100644 --- a/docs/pgn.md +++ b/docs/pgn.md @@ -138,6 +138,12 @@ string. `ToGame()` performs a replay each time, so keep the returned game when you need to continue using it. Coordinate-based move application pays no PGN processing cost. +Export advances one position through the validated mainline, sharing legal-move +generation and SAN formatting with `ChessGame`. It does not build another game +history, undo stack or repetition dictionary. Move numbers use span formatting +with a string fallback for unusually large counters. Parsing the initial FEN, +ordering tags and storing output can still allocate. + Tests cover model ownership, result conflicts, custom FEN/counters, escaping, canonical output, structured diagnostics, unsupported features, special moves, automatic endings and deterministic game round trips through undo. The package diff --git a/docs/repetition.md b/docs/repetition.md new file mode 100644 index 0000000..fda1d9d --- /dev/null +++ b/docs/repetition.md @@ -0,0 +1,80 @@ +# Position repetition + +Repetition tracking answers how often the current position has occurred in the +current game line. `ChessGame.RepetitionCount` includes the initial position. +For example, from the standard start, `Nf3 Nf6 Ng1 Ng8` returns to its second +occurrence. Playing the cycle again reaches the third occurrence, making a draw +claim available. The positions need not be consecutive or reached by the same +sequence of moves. + +## What identifies a position + +| Data | Included in repetition identity? | +| --- | --- | +| Piece types, colors and squares | Yes. | +| Side to move | Yes. | +| Castling rights | Yes, even if castling is currently blocked. | +| En passant target | Only if a legal en passant capture exists. | +| Halfmove clock and fullmove number | No. | + +A rook returning to its original square does not regain a lost castling right. +An en passant target in FEN is insufficient: a pawn must actually be able to +capture there without leaving its own king in check. Thus a pinned pawn may +make that target irrelevant to repetition. + +Threefold repetition permits a claim; fivefold repetition ends the game +automatically. The engine also supports a claim based on a legal intended move +that would produce the third occurrence, without playing that move. These +rules follow [FIDE Laws of Chess, articles 9.2 and 9.6](https://handbook.fide.com/chapter/e012023). +The separate 50/75-move rules use the halfmove clock, not repetition counts. +See [draw APIs and engine boundaries](game-rules-api.md#draws-and-repetition). + +## How the engine counts + +`RepetitionKey` is an internal immutable value containing the relevant position +data. `ChessGame` keeps the current key and a dictionary mapping each distinct +key to its number of occurrences in the current line: + +1. Creating a game adds its initial position with count one. +2. Applying a move creates a key for the resulting position and increments that + key's count. The resulting count feeds draw evaluation. +3. Undo decrements the departing position's count, removes a zero-count entry + and reconstructs the current key from the restored undo position and its + legal moves. That position uses its remaining count. An abandoned + continuation does not affect a new branch. +4. Cloning copies the dictionary; further moves and undo are independent. +5. Checking an intended-move claim computes a candidate key and looks up its + count plus one, without modifying the game or its dictionary. + +The board encoding is exact. Three 64-bit bitplanes represent piece types on +all squares; a fourth marks black pieces. Occupancy follows from the type +planes, so white pieces are the occupied squares not marked black. A separate +integer stores the side, castling rights and normalized en passant target. +Legal moves already generated for the position determine whether en passant +belongs in the key; this does not require another legal-move pass. + +`GetHashCode()` only selects where to look in the dictionary. Equality compares +all stored fields, so two different keys with the same hash remain different +positions. The engine does not use a bare probabilistic hash as position +identity. Keys are internal runtime values, not a serialization format or +stable cross-process identifier. + +This avoids formatting, allocating and hashing a FEN string for every played +position. The dictionary still uses memory and can allocate when it grows. +Undo position snapshots and public history retain their own contracts; +repetition keys do not replace them. There is no separate per-ply key list: +undo already supplies the information needed to reconstruct the current key. + +## Restoring games on a server + +A final FEN alone cannot reveal how many times its position occurred earlier. +`FromFen` therefore starts with count one and no earlier history. Restoring a +`GameRecord` replays its line and reconstructs the counts. Use +`ChessGame.FromRecordJson(json)` for one validated replay directly from JSON. +Neither the dictionary nor its hashes need to be persisted with the record. + +PGN export is different: its immutable model already owns a validated mainline +and result. The writer advances one position to generate canonical SAN and move +numbers, sharing legal-move and SAN code with game play. It does not need a new +history, undo stack or repetition dictionary. Import and `ToGame()` still +validate and reconstruct game state as described in the [PGN guide](pgn.md). diff --git a/src/ChessRealms.Engine.Tests/PgnExportTests.cs b/src/ChessRealms.Engine.Tests/PgnExportTests.cs index 1bea770..3caf383 100644 --- a/src/ChessRealms.Engine.Tests/PgnExportTests.cs +++ b/src/ChessRealms.Engine.Tests/PgnExportTests.cs @@ -56,6 +56,24 @@ public void BlackStartingPosition_UsesFenCountersBeyondInt64_AndInvariantFormatt } } + [TestCase(29)] + [TestCase(30)] + [TestCase(80)] + public void MoveNumbers_PreserveAllDigitsAcrossFormattingBufferAndLineBoundaries(int digits) + { + string number = new('9', digits); + var game = ChessGame.FromFen($"7k/8/8/8/8/8/8/R6K b - - 0 {number}"); + game.MakeSanMove("Kg8"); + game.MakeSanMove("Ra2"); + var document = PgnGame.FromGame(game); + string text = document.ToPgn(); + string movetext = text[(text.IndexOf("\n\n", StringComparison.Ordinal) + 2)..]; + Assert.That(movetext.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries), + Is.EqualTo(new[] { number + "...", "Kg8", "1" + new string('0', digits) + ".", "Ra2", "*" })); + Assert.That(PgnGame.Parse(text).ToGame().ToFen(), Is.EqualTo(game.ToFen())); + Assert.That(document.ToPgn(), Is.EqualTo(text)); + } + [TestCase("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "O-O", "*")] [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "exd6", "*")] [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a8=N", "1/2-1/2")] diff --git a/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs b/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs new file mode 100644 index 0000000..3600e7f --- /dev/null +++ b/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs @@ -0,0 +1,192 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Core.Movements; +using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; +using System.Numerics; + +namespace ChessRealms.Engine.Tests; + +internal class RepetitionKeyTests +{ + [Test] + public void Identity_DistinguishesEveryPieceColorAndSquare() + { + // Geometric fixtures isolate identity; they are not playable games. + var keys = new HashSet { RepetitionKey.Create(new Position(), []) }; + for (int color = 0; color < 2; ++color) + { + for (int piece = 0; piece < 6; ++piece) + { + for (int square = 0; square < 64; ++square) + { + var position = new Position(); + position.SetPieceAt(square, piece, color); + Assert.That(keys.Add(RepetitionKey.Create(position, [])), Is.True, + $"color={color}, piece={piece}, square={square}"); + } + } + } + + Assert.That(keys, Has.Count.EqualTo(769)); + } + + [Test] + public void Identity_DistinguishesSideAndEveryCastlingCombination() + { + var keys = new HashSet(); + var position = Position.CreateDefault(); + for (int color = 0; color < 2; ++color) + { + for (int rights = 0; rights < 16; ++rights) + { + position.color = color; + position.castlings = rights; + Assert.That(keys.Add(RepetitionKey.Create(position, [])), Is.True); + } + } + + Assert.That(keys, Has.Count.EqualTo(32)); + } + + [Test] + public void Identity_IgnoresBothCounters_EvenBeyondInt64() + { + var position = Position.CreateDefault(); + var before = RepetitionKey.Create(position, []); + position.halfMoveClock = BigInteger.Pow(10, 80); + position.fullMoveCount = BigInteger.Pow(10, 90); + var after = RepetitionKey.Create(position, []); + Assert.That(after, Is.EqualTo(before)); + Assert.That(after.GetHashCode(), Is.EqualTo(before.GetHashCode())); + } + + [TestCase("7k/8/8/3p4/8/8/8/7K w - d6 0 1", false)] + [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", true)] + [TestCase("k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1", false)] + [TestCase("7k/8/8/8/3Pp3/8/8/7K b - d3 0 1", true)] + [TestCase("4k3/8/8/8/3Pp3/8/8/K3R3 b - d3 0 1", false)] + public void Identity_RetainsOnlyLegallyAvailableEnPassant(string fen, bool distinct) + { + Assert.That(FenStrings.TryParse(fen, out var position), Is.True); + Span moves = stackalloc int[MoveGen.MaxMoves]; + int count = LegalMoves.Write(position, moves); + var withTarget = RepetitionKey.Create(position, moves[..count]); + position.enpassant = Squares.Empty; + count = LegalMoves.Write(position, moves); + var withoutTarget = RepetitionKey.Create(position, moves[..count]); + Assert.That(withTarget != withoutTarget, Is.EqualTo(distinct)); + } + + [Test] + public void Identity_DistinguishesDifferentLegalEnPassantTargetsOnTheSameBoard() + { + Assert.That(FenStrings.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - b6 0 1", out var left), Is.True); + Assert.That(FenStrings.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - d6 0 1", out var right), Is.True); + Span moves = stackalloc int[MoveGen.MaxMoves]; + int count = LegalMoves.Write(left, moves); + var leftKey = RepetitionKey.Create(left, moves[..count]); + count = LegalMoves.Write(right, moves); + var rightKey = RepetitionKey.Create(right, moves[..count]); + Assert.That(leftKey, Is.Not.EqualTo(rightKey)); + } + + [Test] + public void Dictionary_HashCollisionsDoNotMergeDifferentPositions() + { + var position = Position.CreateDefault(); + var white = RepetitionKey.Create(position, []); + position.SwitchColor(); + var black = RepetitionKey.Create(position, []); + var counts = new Dictionary(new CollidingComparer()) + { + [white] = 3, + [black] = 1 + }; + Assert.That(counts, Has.Count.EqualTo(2)); + Assert.That(counts[white], Is.EqualTo(3)); + Assert.That(counts[black], Is.EqualTo(1)); + } + + [TestCase(17)] + [TestCase(12345)] + public void ReplayUndoAndBranch_MatchNormalizedFenIdentity(int seed) + { + var game = new ChessGame(); + var random = new Random(seed); + var counts = new Dictionary(StringComparer.Ordinal); + var identities = new Dictionary(); + var line = new List(); + AddPosition(); + // Start with an actual repeat, then compare less regular positions too. + foreach (string move in "g1f3 g8f6 f3g1 f6g8".Split(' ')) + { + game.MakeMove(CoordinateMove.Parse(move)); + AddPosition(); + } + + PlayRandom(120); + int undoCount = Math.Min(30, game.PlyCount); + for (int i = 0; i < undoCount; ++i) + { + --counts[line[^1]]; + line.RemoveAt(line.Count - 1); + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.RepetitionCount, Is.EqualTo(counts[line[^1]])); + } + + game = game.Clone(); + PlayRandom(60); + + void PlayRandom(int plies) + { + for (int i = 0; i < plies && !game.IsFinished; ++i) + { + var moves = game.GetLegalMoves(); + Assert.That(game.MakeMove(moves[random.Next(moves.Count)]), Is.Not.EqualTo(MoveResult.None)); + AddPosition(); + } + } + + void AddPosition() + { + var position = game.Position; + Span moves = stackalloc int[MoveGen.MaxMoves]; + int count = LegalMoves.Write(position, moves); + var key = RepetitionKey.Create(position, moves[..count]); + bool legalEnPassant = false; + foreach (int move in moves[..count]) + { + legalEnPassant |= BinaryMoveOps.DecodeEnpassant(move) != 0; + } + + string[] fields = game.ToFen().Split(' '); + if (!legalEnPassant) + { + fields[3] = "-"; + } + + string reference = string.Join(' ', fields.Take(4)); + if (identities.TryGetValue(key, out string? previous)) + { + Assert.That(reference, Is.EqualTo(previous)); + } + else + { + Assert.That(identities.Values, Does.Not.Contain(reference)); + identities.Add(key, reference); + } + + int occurrences = counts.GetValueOrDefault(reference) + 1; + counts[reference] = occurrences; + line.Add(reference); + Assert.That(game.RepetitionCount, Is.EqualTo(occurrences)); + } + } + + private sealed class CollidingComparer : IEqualityComparer + { + public bool Equals(RepetitionKey x, RepetitionKey y) => x.Equals(y); + public int GetHashCode(RepetitionKey obj) => 0; + } +} diff --git a/src/ChessRealms.Engine/ChessGame.San.cs b/src/ChessRealms.Engine/ChessGame.San.cs index e62979b..fa182ed 100644 --- a/src/ChessRealms.Engine/ChessGame.San.cs +++ b/src/ChessRealms.Engine/ChessGame.San.cs @@ -1,21 +1,18 @@ using ChessRealms.Engine.Core.Constants; using ChessRealms.Engine.Core.Math; using ChessRealms.Engine.Core.Movements; -using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; 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]; + Span text = stackalloc char[SanFormatter.MaxLength]; 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)); @@ -34,14 +31,14 @@ public bool TryFormatSan(CoordinateMove move, Span destination, out int ch } Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int count = WriteLegalMoves(position, legalMoves); - if (!TryFindMove(move, legalMoves[..count], out int encodedMove)) + int count = LegalMoves.Write(position, legalMoves); + if (!LegalMoves.TryFind(move, legalMoves[..count], out int encodedMove)) { return false; } - Span text = stackalloc char[MaxSanLength]; - int length = WriteSan(position, encodedMove, legalMoves[..count], text); + Span text = stackalloc char[SanFormatter.MaxLength]; + int length = SanFormatter.Write(position, encodedMove, legalMoves[..count], text); if (destination.Length < length) { return false; @@ -70,7 +67,7 @@ public bool TryParseSan(ReadOnlySpan text, out CoordinateMove move) return false; } - move = ToPublicMove(encodedMove); + move = LegalMoves.ToCoordinateMove(encodedMove); return true; } @@ -79,7 +76,7 @@ public bool TryParseSan(ReadOnlySpan text, out CoordinateMove move) /// 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) + ? ApplyLegalMove(LegalMoves.ToCoordinateMove(encodedMove), encodedMove) : MoveResult.None; private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove) @@ -91,7 +88,7 @@ private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove) } Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int count = WriteLegalMoves(position, legalMoves); + int count = LegalMoves.Write(position, legalMoves); int selected = 0; foreach (int candidate in legalMoves[..count]) { @@ -116,8 +113,8 @@ private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove) // 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); + Span canonical = stackalloc char[SanFormatter.MaxLength]; + int length = SanFormatter.Write(position, selected, legalMoves[..count], canonical); if (!text.SequenceEqual(canonical[..length])) { return false; @@ -127,93 +124,10 @@ private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove) 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) + if (text.Length is < 2 or > SanFormatter.MaxLength) { return false; } diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 5a289df..0b0c8b2 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -19,8 +19,8 @@ public sealed partial class ChessGame private readonly string initialFen; private readonly List history = []; private readonly List undo = []; - private readonly List keys = []; - private readonly Dictionary repetitions = new(StringComparer.Ordinal); + private RepetitionKey currentRepetitionKey; + private readonly Dictionary repetitions; public PieceColor CurrentColor => position.color.ToPublicColor(); public PieceColor OpponentColor => Colors.Mirror(position.color).ToPublicColor(); @@ -48,7 +48,7 @@ public GameState State } } - public int RepetitionCount => repetitions[keys[^1]]; + public int RepetitionCount => repetitions[currentRepetitionKey]; /// The number of recorded half-moves since the initial position, excluding undone moves. public int PlyCount => history.Count; @@ -73,10 +73,9 @@ internal ChessGame(Position position) this.position = position; initialFen = FenStrings.Format(position); Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int legalMoveCount = WriteLegalMoves(position, legalMoves); - string key = CreateRepetitionKey(position, legalMoves[..legalMoveCount]); - keys.Add(key); - repetitions.Add(key, 1); + int legalMoveCount = LegalMoves.Write(position, legalMoves); + currentRepetitionKey = RepetitionKey.Create(position, legalMoves[..legalMoveCount]); + repetitions = new Dictionary { [currentRepetitionKey] = 1 }; Outcome = EvaluateOutcome(position, legalMoveCount, 1); } @@ -88,11 +87,8 @@ private ChessGame(ChessGame original) DrawIntendedMove = original.DrawIntendedMove; history.AddRange(original.history); undo.AddRange(original.undo); - keys.AddRange(original.keys); - foreach (var pair in original.repetitions) - { - repetitions.Add(pair.Key, pair.Value); - } + currentRepetitionKey = original.currentRepetitionKey; + repetitions = new Dictionary(original.repetitions); } public ChessGame Clone() => new(this); @@ -189,11 +185,11 @@ public IReadOnlyList GetLegalMoves() } Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int legalMoveCount = WriteLegalMoves(position, legalMoves); + int legalMoveCount = LegalMoves.Write(position, legalMoves); var moves = new CoordinateMove[legalMoveCount]; for (int i = 0; i < legalMoveCount; ++i) { - moves[i] = ToPublicMove(legalMoves[i]); + moves[i] = LegalMoves.ToCoordinateMove(legalMoves[i]); } return Array.AsReadOnly(moves); @@ -219,8 +215,8 @@ private MoveResult ApplyLegalMove(CoordinateMove move, int encodedMove) nextPosition.SwitchColor(); Span replies = stackalloc int[MoveGen.MaxMoves]; - int replyCount = WriteLegalMoves(nextPosition, replies); - string key = CreateRepetitionKey(nextPosition, replies[..replyCount]); + int replyCount = LegalMoves.Write(nextPosition, replies); + RepetitionKey key = RepetitionKey.Create(nextPosition, replies[..replyCount]); int repetitionCount = repetitions.GetValueOrDefault(key) + 1; var outcome = EvaluateOutcome(nextPosition, replyCount, repetitionCount); @@ -248,7 +244,7 @@ private MoveResult ApplyLegalMove(CoordinateMove move, int encodedMove) var entry = new RecordedMove(move, result); undo.Add(position); history.Add(entry); - keys.Add(key); + currentRepetitionKey = key; repetitions[key] = repetitionCount; position = nextPosition; Outcome = outcome; @@ -264,18 +260,20 @@ public bool UndoMove() return false; } - string key = keys[^1]; + RepetitionKey key = currentRepetitionKey; if (--repetitions[key] == 0) { repetitions.Remove(key); } - keys.RemoveAt(keys.Count - 1); position = undo[^1]; undo.RemoveAt(undo.Count - 1); history.RemoveAt(history.Count - 1); Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int legalMoveCount = WriteLegalMoves(position, legalMoves); + int legalMoveCount = LegalMoves.Write(position, legalMoves); + // Undo already retains the position and regenerates its legal moves. + // Reuse them instead of storing another key for every ply. + currentRepetitionKey = RepetitionKey.Create(position, legalMoves[..legalMoveCount]); Outcome = EvaluateOutcome(position, legalMoveCount, RepetitionCount); DrawIntendedMove = null; @@ -295,8 +293,8 @@ public DrawClaim GetAvailableDrawClaims(CoordinateMove intendedMove) nextPosition.SwitchColor(); Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int legalMoveCount = WriteLegalMoves(nextPosition, legalMoves); - string key = CreateRepetitionKey(nextPosition, legalMoves[..legalMoveCount]); + int legalMoveCount = LegalMoves.Write(nextPosition, legalMoves); + RepetitionKey key = RepetitionKey.Create(nextPosition, legalMoves[..legalMoveCount]); int repetitionCount = repetitions.GetValueOrDefault(key) + 1; return CalculateDrawClaims(nextPosition, repetitionCount); } @@ -336,68 +334,8 @@ private bool TryFindMove(CoordinateMove move, out int encodedMove) } Span legalMoves = stackalloc int[MoveGen.MaxMoves]; - int legalMoveCount = WriteLegalMoves(position, legalMoves); - 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) - { - continue; - } - - encodedMove = candidate; - return true; - } - - return false; - } - - private static CoordinateMove ToPublicMove(int encodedMove) => new( - new Square(BinaryMoveOps.DecodeSrc(encodedMove)), - new Square(BinaryMoveOps.DecodeTrg(encodedMove)), - BinaryMoveOps.DecodePromotion(encodedMove).ToPublicPromotion()); - - private static int WriteLegalMoves(Position position, Span moves) - { - int written = MoveGen.WriteMoves(ref position, position.color, moves); - int legalMoveCount = 0; - for (int i = 0; i < written; i++) - { - var nextPosition = position; - // Move legality is independent of FEN counters. - MoveDriver.MakeMove(ref nextPosition, moves[i], updateCounters: false); - if (!nextPosition.IsKingChecked(position.color)) - { - moves[legalMoveCount++] = moves[i]; - } - } - - return legalMoveCount; - } - - private static string CreateRepetitionKey(Position position, ReadOnlySpan legalMoves) - { - bool hasLegalEnPassant = false; - foreach (int move in legalMoves) - { - if (BinaryMoveOps.DecodeEnpassant(move) != 0) - { - hasLegalEnPassant = true; - break; - } - } - - if (!hasLegalEnPassant) - { - position.enpassant = Squares.Empty; - } - - return FenStrings.FormatPositionFields(position); + int legalMoveCount = LegalMoves.Write(position, legalMoves); + return LegalMoves.TryFind(move, legalMoves[..legalMoveCount], out encodedMove); } private static DrawClaim CalculateDrawClaims(Position position, int repetitionCount) => diff --git a/src/ChessRealms.Engine/Core/Movements/LegalMoves.cs b/src/ChessRealms.Engine/Core/Movements/LegalMoves.cs new file mode 100644 index 0000000..8af5b78 --- /dev/null +++ b/src/ChessRealms.Engine/Core/Movements/LegalMoves.cs @@ -0,0 +1,50 @@ +using ChessRealms.Engine.Core.Extensions; +using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Core.Types; + +namespace ChessRealms.Engine.Core.Movements; + +// Board legality is shared by game play, SAN and validated PGN export. Game +// outcomes such as repetition draws are evaluated separately by ChessGame. +internal static class LegalMoves +{ + internal static int Write(Position position, Span moves) + { + int written = MoveGen.WriteMoves(ref position, position.color, moves); + int legalMoveCount = 0; + for (int i = 0; i < written; i++) + { + var nextPosition = position; + // Move legality is independent of FEN counters. + MoveDriver.MakeMove(ref nextPosition, moves[i], updateCounters: false); + if (!nextPosition.IsKingChecked(position.color)) + { + moves[legalMoveCount++] = moves[i]; + } + } + + return legalMoveCount; + } + + internal static bool TryFind(CoordinateMove move, ReadOnlySpan legalMoves, out int encodedMove) + { + encodedMove = 0; + foreach (int candidate in legalMoves) + { + if (ToCoordinateMove(candidate) != move) + { + continue; + } + + encodedMove = candidate; + return true; + } + + return false; + } + + internal static CoordinateMove ToCoordinateMove(int encodedMove) => new( + new Square(BinaryMoveOps.DecodeSrc(encodedMove)), + new Square(BinaryMoveOps.DecodeTrg(encodedMove)), + BinaryMoveOps.DecodePromotion(encodedMove).ToPublicPromotion()); +} diff --git a/src/ChessRealms.Engine/Core/Types/RepetitionKey.cs b/src/ChessRealms.Engine/Core/Types/RepetitionKey.cs new file mode 100644 index 0000000..491e9fb --- /dev/null +++ b/src/ChessRealms.Engine/Core/Types/RepetitionKey.cs @@ -0,0 +1,68 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Math; + +namespace ChessRealms.Engine.Core.Types; + +/// Exact position identity for repetition, excluding move counters. +internal readonly struct RepetitionKey : IEquatable +{ + // Three bitplanes encode empty=0, pawn=1, knight=2, bishop=3, rook=4, + // queen=5, king=6 on all 64 squares; the fourth records black occupancy. + // This is a lossless board encoding, not a probabilistic board hash. + private readonly ulong typeLow; + private readonly ulong typeMiddle; + private readonly ulong typeHigh; + private readonly ulong blackPieces; + private readonly int state; + + private RepetitionKey(in Position position, int enPassant) + { + ulong pawns = position.pieceBBs[BitboardIndicies.BPawn] | position.pieceBBs[BitboardIndicies.WPawn]; + ulong knights = position.pieceBBs[BitboardIndicies.BKnight] | position.pieceBBs[BitboardIndicies.WKnight]; + ulong bishops = position.pieceBBs[BitboardIndicies.BBishop] | position.pieceBBs[BitboardIndicies.WBishop]; + ulong rooks = position.pieceBBs[BitboardIndicies.BRook] | position.pieceBBs[BitboardIndicies.WRook]; + ulong queens = position.pieceBBs[BitboardIndicies.BQueen] | position.pieceBBs[BitboardIndicies.WQueen]; + ulong kings = position.pieceBBs[BitboardIndicies.BKing] | position.pieceBBs[BitboardIndicies.WKing]; + typeLow = pawns | bishops | queens; + typeMiddle = knights | bishops | kings; + typeHigh = rooks | queens | kings; + blackPieces = position.pieceBBs[BitboardIndicies.BPawn] | position.pieceBBs[BitboardIndicies.BKnight] + | position.pieceBBs[BitboardIndicies.BBishop] | position.pieceBBs[BitboardIndicies.BRook] + | position.pieceBBs[BitboardIndicies.BQueen] | position.pieceBBs[BitboardIndicies.BKing]; + + // One side bit, four castling bits, then en passant (zero means absent). + state = position.color | (position.castlings << 1) | ((enPassant + 1) << 5); + } + + // The caller supplies the legal moves it already generated. A pinned pawn + // does not retain an en passant right when that capture would expose its king. + internal static RepetitionKey Create(in Position position, ReadOnlySpan legalMoves) + { + int enPassant = Squares.Empty; + if (position.enpassant != Squares.Empty) + { + foreach (int move in legalMoves) + { + if (BinaryMoveOps.DecodeEnpassant(move) != 0) + { + enPassant = position.enpassant; + break; + } + } + } + + return new RepetitionKey(position, enPassant); + } + + public bool Equals(RepetitionKey other) => typeLow == other.typeLow + && typeMiddle == other.typeMiddle && typeHigh == other.typeHigh + && blackPieces == other.blackPieces && state == other.state; + + public override bool Equals(object? obj) => obj is RepetitionKey other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(typeLow, typeMiddle, typeHigh, blackPieces, state); + + public static bool operator ==(RepetitionKey left, RepetitionKey right) => left.Equals(right); + + public static bool operator !=(RepetitionKey left, RepetitionKey right) => !left.Equals(right); +} diff --git a/src/ChessRealms.Engine/Parsing/PgnWriter.cs b/src/ChessRealms.Engine/Parsing/PgnWriter.cs index 77331ab..176fc01 100644 --- a/src/ChessRealms.Engine/Parsing/PgnWriter.cs +++ b/src/ChessRealms.Engine/Parsing/PgnWriter.cs @@ -1,4 +1,7 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Movements; using System.Globalization; +using System.Numerics; namespace ChessRealms.Engine.Parsing; @@ -20,32 +23,57 @@ internal static void Write(PgnGame document, TextWriter writer) } writer.Write('\n'); - var replay = ChessGame.FromFen(document.InitialFen); + if (!FenStrings.TryParse(document.InitialFen, out var position)) + { + throw new InvalidOperationException("The validated PGN starting position could not be loaded."); + } + + // PgnGame owns an already validated line. Export needs board legality + // and SAN, but no game history, undo stack or repetition accounting. int column = 0; - Span san = stackalloc char[7]; + Span san = stackalloc char[SanFormatter.MaxLength]; + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; for (int i = 0; i < document.Moves.Count; ++i) { - if (replay.CurrentColor == PieceColor.White || i == 0) + if (position.color == Colors.White || i == 0) { - string number = replay.FullmoveNumber.ToString(CultureInfo.InvariantCulture) - + (replay.CurrentColor == PieceColor.White ? "." : "..."); - WriteToken(writer, number, ref column); + WriteMoveNumber(writer, position.fullMoveCount, position.color == Colors.Black, ref column); } CoordinateMove move = document.Moves[i]; - if (!replay.TryFormatSan(move, san, out int written)) + int count = LegalMoves.Write(position, legalMoves); + if (!LegalMoves.TryFind(move, legalMoves[..count], out int encodedMove)) { throw new InvalidOperationException("The validated PGN line could not be replayed."); } + int written = SanFormatter.Write(position, encodedMove, legalMoves[..count], san); WriteToken(writer, san[..written], ref column); - replay.MakeMove(move); + MoveDriver.MakeMove(ref position, encodedMove); + position.SwitchColor(); } WriteToken(writer, PgnTags.ResultText(document.Result), ref column); writer.Write("\n\n"); } + private static void WriteMoveNumber(TextWriter writer, BigInteger number, bool black, ref int column) + { + Span token = stackalloc char[32]; + ReadOnlySpan suffix = black ? "..." : "."; + if (number.TryFormat(token[..^3], out int written, provider: CultureInfo.InvariantCulture)) + { + suffix.CopyTo(token[written..]); + WriteToken(writer, token[..(written + suffix.Length)], ref column); + } + else + { + // Counters are unbounded. Keep the uncommon large-number path exact. + string text = number.ToString(CultureInfo.InvariantCulture) + (black ? "..." : "."); + WriteToken(writer, text, ref column); + } + } + private static void WriteTag(TextWriter writer, string name, string value) { writer.Write('['); diff --git a/src/ChessRealms.Engine/Parsing/SanFormatter.cs b/src/ChessRealms.Engine/Parsing/SanFormatter.cs new file mode 100644 index 0000000..c4c0c90 --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/SanFormatter.cs @@ -0,0 +1,95 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Core.Movements; +using ChessRealms.Engine.Core.Types; + +namespace ChessRealms.Engine.Parsing; + +internal static class SanFormatter +{ + // Canonical SAN is at most seven ASCII characters, for example Qa6xb7#. + internal const int MaxLength = 7; + + internal static int Write(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++] = LegalMoves.Write(after, replies) == 0 ? '#' : '+'; + } + + return length; + } +} From 7f03247f32f7dc9b6ad677c668cd766bb4e9cf63 Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 22 Oct 2026 23:09:40 +0300 Subject: [PATCH 3/4] refactor(fen): separate parsing and formatting --- .../CoreBenchmarks.cs | 2 +- .../FenBenchmarks.cs | 6 +- .../PerftBenchmarks.cs | 2 +- src/ChessRealms.Engine.Perft/Program.cs | 2 +- .../ChessGameTests.cs | 8 +- .../CompleteGameRulesTests.cs | 12 +- .../Core/MoveDriverTests/ApplyEnpassant.cs | 8 +- .../Core/MoveGeneration/AllMovesTests.cs | 2 +- .../MoveGeneration/Knight/KnightMovesTests.cs | 2 +- .../MoveGeneration/Pawn/EnpassantMoveTests.cs | 4 +- .../MoveGeneration/Pawn/PromoteMoveTests.cs | 2 +- .../MoveGeneration/Pawn/QuietMoveTests.cs | 2 +- .../HistoryFenTests.cs | 12 +- .../Parsing/FenFormattingTests.cs | 14 +- .../Parsing/StrictFenTests.cs | 14 +- .../Perft/PerftTests.cs | 6 +- .../RepetitionKeyTests.cs | 6 +- src/ChessRealms.Engine/ChessGame.History.cs | 4 +- src/ChessRealms.Engine/ChessGame.cs | 8 +- src/ChessRealms.Engine/GameRecord.cs | 2 +- .../Parsing/FenFormatter.cs | 193 ++++++++++ src/ChessRealms.Engine/Parsing/FenParser.cs | 173 +++++++++ src/ChessRealms.Engine/Parsing/FenStrings.cs | 349 ------------------ src/ChessRealms.Engine/Parsing/PgnWriter.cs | 2 +- 24 files changed, 425 insertions(+), 410 deletions(-) create mode 100644 src/ChessRealms.Engine/Parsing/FenFormatter.cs create mode 100644 src/ChessRealms.Engine/Parsing/FenParser.cs delete mode 100644 src/ChessRealms.Engine/Parsing/FenStrings.cs diff --git a/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs index ea61d08..b83f393 100644 --- a/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs @@ -38,5 +38,5 @@ public int GeneratePseudoLegalMoves() public ulong PerftDepth3() => Perft.PerftDriver.Test(position, 3, false).Nodes; [Benchmark] - public string FormatFen() => FenStrings.FormatUnchecked(position); + public string FormatFen() => FenFormatter.FormatWithoutValidation(position); } diff --git a/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs index f5a1bb6..aca1085 100644 --- a/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/FenBenchmarks.cs @@ -28,7 +28,7 @@ public void Setup() game = ChessGame.FromFen(fen); position = game.Position; destination = new char[fen.Length]; - if (FenStrings.FormatUnchecked(position) != fen) + if (FenFormatter.FormatWithoutValidation(position) != fen) { throw new InvalidOperationException("Invalid FEN benchmark fixture."); } @@ -40,12 +40,12 @@ public void Setup() } [Benchmark] - public string FormatString() => FenStrings.FormatUnchecked(position); + public string FormatString() => FenFormatter.FormatWithoutValidation(position); [Benchmark] public int FormatSpan() => game.TryFormatFen(destination, out int written) ? written : throw new InvalidOperationException("The FEN buffer must fit the fixture."); [Benchmark] - public string FormatPositionFields() => FenStrings.FormatPositionFields(position); + public string FormatPositionFields() => FenFormatter.FormatWithoutCounters(position); } diff --git a/src/ChessRealms.Engine.Benchmark/PerftBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PerftBenchmarks.cs index 6ce263b..71fd6f3 100644 --- a/src/ChessRealms.Engine.Benchmark/PerftBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/PerftBenchmarks.cs @@ -12,7 +12,7 @@ public class PerftBenchmarks public PerftBenchmarks() { - _ = FenStrings.TryParse(FenStrings.StartPosition, out position); + _ = FenParser.TryParse(ChessGame.StartingFen, out position); AttackLookups.InvokeInit(); } diff --git a/src/ChessRealms.Engine.Perft/Program.cs b/src/ChessRealms.Engine.Perft/Program.cs index 372b6a3..a9698ad 100644 --- a/src/ChessRealms.Engine.Perft/Program.cs +++ b/src/ChessRealms.Engine.Perft/Program.cs @@ -12,7 +12,7 @@ AttackLookups.InvokeInit(); -_ = FenStrings.TryParse(fen, out Position pos); +_ = FenParser.TryParse(fen, out Position pos); Stopwatch stopwatch = Stopwatch.StartNew(); diff --git a/src/ChessRealms.Engine.Tests/ChessGameTests.cs b/src/ChessRealms.Engine.Tests/ChessGameTests.cs index ede8429..1019084 100644 --- a/src/ChessRealms.Engine.Tests/ChessGameTests.cs +++ b/src/ChessRealms.Engine.Tests/ChessGameTests.cs @@ -1,5 +1,3 @@ -using ChessRealms.Engine.Parsing; - namespace ChessRealms.Engine.Tests; internal class ChessGameTests @@ -84,9 +82,9 @@ public void Capture_RemovesEnemyAndMovesAttacker() Assert.That(game!.CurrentColor, Is.EqualTo(PieceColor.Black)); } - [TestCase(FenStrings.StartPosition, "e2e5")] - [TestCase(FenStrings.StartPosition, "e7e5")] - [TestCase(FenStrings.StartPosition, "a3a4")] + [TestCase(ChessGame.StartingFen, "e2e5")] + [TestCase(ChessGame.StartingFen, "e7e5")] + [TestCase(ChessGame.StartingFen, "a3a4")] [TestCase("k3r3/8/8/8/8/8/4R3/4K3 w - - 0 1", "e2f2")] public void IllegalMove_PreservesBoardTurnAndFinishedState(string fen, string move) { diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index a32af81..177e724 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -73,12 +73,12 @@ public void CoordinateInput_RoundTrips(string input, PieceValue promotion) public void InitialPosition_ClocksRightsAndPositionCopy() { ChessGame game = new(); - Assert.That(game.ToFen(), Is.EqualTo(FenStrings.StartPosition)); - Assert.That(FenStrings.Format(Position.CreateDefault()), Is.EqualTo(FenStrings.StartPosition)); + Assert.That(game.ToFen(), Is.EqualTo(ChessGame.StartingFen)); + Assert.That(FenFormatter.Format(Position.CreateDefault()), Is.EqualTo(ChessGame.StartingFen)); Assert.That(game.GetLegalMoves(), Has.Count.EqualTo(20)); var snapshot = game.Position; snapshot.PopPieceAt(Squares.a1, Pieces.Rook, Colors.White); - Assert.That(game.ToFen(), Is.EqualTo(FenStrings.StartPosition)); + Assert.That(game.ToFen(), Is.EqualTo(ChessGame.StartingFen)); } [Test] @@ -457,9 +457,9 @@ public void DeterministicPlayouts_AllLegalMovesApplyToClones_AndUndoExactly(int AssertInvariants(copy.Position); 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(FenParser.TryParse(copy.ToFen(), out var imported), Is.True); Assert.That(imported, Is.EqualTo(copy.Position)); - Assert.That(FenStrings.Format(imported), Is.EqualTo(copy.ToFen())); + Assert.That(FenFormatter.Format(imported), Is.EqualTo(copy.ToFen())); Assert.That(copy.UndoMove(), Is.True); Assert.That(copy.Position, Is.EqualTo(positionBefore)); Assert.That(State(copy), Is.EqualTo(before)); @@ -472,7 +472,7 @@ public void DeterministicPlayouts_AllLegalMovesApplyToClones_AndUndoExactly(int for (int i = states.Count - 2; i >= 0; i--) { Assert.That(game.UndoMove(), Is.True); - Assert.That(game.ToFen(), Is.EqualTo(FenStrings.Format(game.Position))); + Assert.That(game.ToFen(), Is.EqualTo(FenFormatter.Format(game.Position))); Assert.That(State(game), Is.EqualTo(states[i])); } Assert.That(game.UndoMove(), Is.False); diff --git a/src/ChessRealms.Engine.Tests/Core/MoveDriverTests/ApplyEnpassant.cs b/src/ChessRealms.Engine.Tests/Core/MoveDriverTests/ApplyEnpassant.cs index 3d72477..25a9c88 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveDriverTests/ApplyEnpassant.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveDriverTests/ApplyEnpassant.cs @@ -13,7 +13,7 @@ public unsafe void Apply_EP_Black() { const string fen = "rnbqkbnr/p1pppppp/8/5P2/1pP5/8/PP1PP1PP/RNBQKBNR b KQkq c3 0 1"; - Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); + Assert.That(FenParser.TryParse(fen, out Position position), Is.True); int epMove = BinaryMoveOps.EncodeMove( Squares.b4, Pieces.Pawn, Colors.Black, Squares.c3, @@ -38,7 +38,7 @@ public void Apply_EP_White() { const string fen = "rnbqkbnr/p1pppp1p/8/5Pp1/1pP5/8/PP1PP1PP/RNBQKBNR w KQkq g6 0 1"; - Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); + Assert.That(FenParser.TryParse(fen, out Position position), Is.True); int epMove = BinaryMoveOps.EncodeMove( Squares.f5, Pieces.Pawn, Colors.White, Squares.g6, @@ -63,7 +63,7 @@ public void Apply_EP_White_H8() { const string fen = "rnbqkbnr/1ppppp1p/8/p5pP/8/8/PPPPPPP1/RNBQKBNR w KQkq g6 0 1"; - Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); + Assert.That(FenParser.TryParse(fen, out Position position), Is.True); int epMove = BinaryMoveOps.EncodeMove( Squares.h5, Pieces.Pawn, Colors.White, Squares.g6, @@ -88,7 +88,7 @@ public void Apply_EP_Black_D3() { const string fen = "rnbqkbnr/p1p1pppp/5P2/8/1pPp4/8/PP1PP1PP/RNBQKBNR b KQkq c3 0 1"; - Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); + Assert.That(FenParser.TryParse(fen, out Position position), Is.True); int epMove = BinaryMoveOps.EncodeMove( Squares.b4, Pieces.Pawn, Colors.Black, Squares.c3, diff --git a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/AllMovesTests.cs b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/AllMovesTests.cs index 6861171..35af41d 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/AllMovesTests.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/AllMovesTests.cs @@ -11,7 +11,7 @@ internal unsafe class AllMovesTests [Test] public void Test1_AsBlack() { - Assert.That(FenStrings.TryParse(fen, out Position position), Is.True); + Assert.That(FenParser.TryParse(fen, out Position position), Is.True); Span moves = stackalloc int[MoveGen.MaxMoves]; int written = MoveGen.WriteMoves(ref position, position.color, moves); diff --git a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs index 81cb0c1..f961ae8 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Knight/KnightMovesTests.cs @@ -28,7 +28,7 @@ internal unsafe class KnightMovesTests public KnightMovesTests() { // Deliberately synthetic geometry fixture: no kings and pawns on back ranks. - Assert.That(FenStrings.TryParseSyntax(fen, out position), Is.True); + Assert.That(FenParser.TryParseSyntaxOnly(fen, out position), Is.True); } [Test] diff --git a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs index 7e96583..d238e9c 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/EnpassantMoveTests.cs @@ -29,7 +29,7 @@ public void Test_1_AsWhite() string fen = "rnbqkbnr/1pppp1p1/8/pP6/5pPp/4P3/P1PP1P1P/RNBQKBNR w KQkq a6 0 1"; - if (!FenStrings.TryParse(fen, out Position position)) + if (!FenParser.TryParse(fen, out Position position)) { Assert.Fail(); return; @@ -74,7 +74,7 @@ public void Test_1_AsBlack() string fen = "rnbqkbnr/1pppp1p1/8/pP6/5pPp/4P3/P1PP1P1P/RNBQKBNR b KQkq g3 0 1"; - if (!FenStrings.TryParse(fen, out Position position)) + if (!FenParser.TryParse(fen, out Position position)) { Assert.Fail(); return; diff --git a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs index 420f7e7..42303f7 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/PromoteMoveTests.cs @@ -28,7 +28,7 @@ internal unsafe class PromoteMoveTests public PromoteMoveTests() { - _ = FenStrings.TryParse(fen, out position); + _ = FenParser.TryParse(fen, out position); } [Test] diff --git a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs index a16d37d..5e5f305 100644 --- a/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs +++ b/src/ChessRealms.Engine.Tests/Core/MoveGeneration/Pawn/QuietMoveTests.cs @@ -24,7 +24,7 @@ public void QuietMoves_White_StartPos() // 2 P P P P P P P P // 1 R N B Q K B N R - if (!FenStrings.TryParse(FenStrings.StartPosition, out Position position)) + if (!FenParser.TryParse(ChessGame.StartingFen, out Position position)) { Assert.Fail(ParseFenError); return; diff --git a/src/ChessRealms.Engine.Tests/HistoryFenTests.cs b/src/ChessRealms.Engine.Tests/HistoryFenTests.cs index 4592d9a..9f86a1c 100644 --- a/src/ChessRealms.Engine.Tests/HistoryFenTests.cs +++ b/src/ChessRealms.Engine.Tests/HistoryFenTests.cs @@ -20,7 +20,7 @@ public void DeferredHistoryMatchesEveryPositionAndMoveFlag(string initialFen, st { var game = ChessGame.FromFen(initialFen); var moves = ParseLine(line); - var fens = new List { FenStrings.Format(game.Position) }; + var fens = new List { FenFormatter.Format(game.Position) }; var results = new List(); foreach (var move in moves) { @@ -28,7 +28,7 @@ public void DeferredHistoryMatchesEveryPositionAndMoveFlag(string initialFen, st Assert.That(result, Is.Not.EqualTo(MoveResult.None)); results.Add(result); // Build expectations without querying the game's lazy FEN or history APIs. - fens.Add(FenStrings.Format(game.Position)); + fens.Add(FenFormatter.Format(game.Position)); } var clone = game.Clone(); @@ -71,14 +71,14 @@ public void SnapshotsAndColdOrWarmClonesSurviveUndoAndDifferentContinuations(boo Assert.That(game.ToFen(), Is.SameAs(unchangedPrefix)); Play(game, "f1c4"); Assert.That(game.PlyCount, Is.EqualTo(3)); - Assert.That(game.ToFen(), Is.EqualTo(FenStrings.Format(game.Position))); + Assert.That(game.ToFen(), Is.EqualTo(FenFormatter.Format(game.Position))); Assert.That(game.GetFenAtPly(3), Is.Not.EqualTo(snapshot[2].FenAfter)); Assert.Throws(() => game.GetFenAtPly(4)); Assert.That(clone.UndoMove(), Is.True); Play(clone, "d7d6"); Assert.That(clone.PlyCount, Is.EqualTo(4)); - Assert.That(clone.ToFen(), Is.EqualTo(FenStrings.Format(clone.Position))); + Assert.That(clone.ToFen(), Is.EqualTo(FenFormatter.Format(clone.Position))); Assert.That(clone.GetFenAtPly(3), Is.EqualTo(snapshot[2].FenAfter)); Assert.That(clone.GetFenAtPly(4), Is.Not.EqualTo(snapshot[3].FenAfter)); Assert.That(game.GetFenAtPly(2), Is.SameAs(unchangedPrefix)); @@ -173,11 +173,11 @@ public void LargeHistoricalCountersUseTheCorrectStateAndRespectSpanBoundaries() { string fullmove = new('9', 4096); var game = ChessGame.FromFen($"4k3/8/8/8/8/8/8/R3K3 b - - 7 {fullmove}"); - var expected = new List { FenStrings.Format(game.Position) }; + var expected = new List { FenFormatter.Format(game.Position) }; foreach (var move in ParseLine("e8e7 a1a2")) { Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); - expected.Add(FenStrings.Format(game.Position)); + expected.Add(FenFormatter.Format(game.Position)); } for (int ply = 0; ply < expected.Count; ply++) diff --git a/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs b/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs index f326311..31a48f5 100644 --- a/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs +++ b/src/ChessRealms.Engine.Tests/Parsing/FenFormattingTests.cs @@ -32,7 +32,7 @@ public void FormatsCanonicalFenIntoExactAndLargerSpans(string fen) { var game = ChessGame.FromFen(fen); AssertFormatting(game, fen); - Assert.That(FenStrings.FormatPositionFields(game.Position), Is.EqualTo(string.Join(' ', fen.Split(' ').Take(4)))); + Assert.That(FenFormatter.FormatWithoutCounters(game.Position), Is.EqualTo(string.Join(' ', fen.Split(' ').Take(4)))); } [TestCaseSource(nameof(CanonicalFens))] @@ -86,16 +86,16 @@ public void FormattingIsInvariantAndNormalizesLeadingZeroCounters() } [Test] - public void UncheckedFormatterSupportsTheMaximumPositionFieldLength() + public void FormatterWithoutValidationSupportsTheMaximumLengthWithoutCounters() { // Internal geometric fixtures may occupy all squares and omit kings. string fields = $"{string.Join('/', Enumerable.Repeat("pppppppp", 8))} w KQkq h6"; string fen = fields + " 0 1"; - Assert.That(FenStrings.TryParseSyntax(fen, out var position), Is.True); - Assert.That(FenStrings.FormatUnchecked(position), Is.EqualTo(fen)); - Assert.That(FenStrings.FormatPositionFields(position), Is.EqualTo(fields)); + Assert.That(FenParser.TryParseSyntaxOnly(fen, out var position), Is.True); + Assert.That(FenFormatter.FormatWithoutValidation(position), Is.EqualTo(fen)); + Assert.That(FenFormatter.FormatWithoutCounters(position), Is.EqualTo(fields)); Span buffer = stackalloc char[fen.Length]; - Assert.That(FenStrings.TryFormatUnchecked(position, buffer, out int written), Is.True); + Assert.That(FenFormatter.TryFormatWithoutValidation(position, buffer, out int written), Is.True); Assert.That(new string(buffer[..written]), Is.EqualTo(fen)); } @@ -132,7 +132,7 @@ public void FormattingTracksMovesAndUndoWithoutChangingHistoryOrDrawClaims() private static void AssertFormatting(ChessGame game, string expected) { - Assert.That(FenStrings.Format(game.Position), Is.EqualTo(expected)); + Assert.That(FenFormatter.Format(game.Position), Is.EqualTo(expected)); Assert.That(game.ToFen(), Is.EqualTo(expected)); char[] buffer = Enumerable.Repeat('?', expected.Length + 6).ToArray(); foreach (int capacity in new[] { expected.Length, expected.Length + 2 }) diff --git a/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs b/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs index 00d15b3..ad82a18 100644 --- a/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs +++ b/src/ChessRealms.Engine.Tests/Parsing/StrictFenTests.cs @@ -60,7 +60,7 @@ internal class StrictFenTests [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6x 0 1")] public void InvalidFen_IsRejectedWithoutPartialPosition(string? fen) { - Assert.That(FenStrings.TryParse(fen, out var position), Is.False); + Assert.That(FenParser.TryParse(fen, out var position), Is.False); Assert.That(ChessGame.TryCreateFromFen(fen, out var game), Is.False); Assert.That(game, Is.Null); Assert.Throws(() => new ChessGame(position)); @@ -69,7 +69,7 @@ public void InvalidFen_IsRejectedWithoutPartialPosition(string? fen) [TestCase("7k/8/8/8/8/8/8/7K w - - 9223372036854775808 1")] [TestCase("7k/8/8/8/8/8/8/7K w - - 0 9223372036854775808")] [TestCase("7k/8/8/8/8/8/8/7K w - - 999999999999999999999999999999999999 1")] - [TestCase(FenStrings.StartPosition)] + [TestCase(ChessGame.StartingFen)] [TestCase("7k/8/8/3p4/8/8/8/7K w - d6 0 1")] [TestCase("7k/8/8/8/3P4/8/8/7K b - d3 0 1")] [TestCase("k3r3/8/8/3pP3/8/8/8/4K3 w - d6 0 1")] @@ -78,23 +78,23 @@ public void InvalidFen_IsRejectedWithoutPartialPosition(string? fen) [TestCase("4k3/8/8/8/8/8/8/K3R3 b - - 0 1")] public void ValidFen_RoundTripsExactly(string fen) { - Assert.That(FenStrings.TryParse(fen, out var position), Is.True); - Assert.That(FenStrings.Format(position), Is.EqualTo(fen)); + Assert.That(FenParser.TryParse(fen, out var position), Is.True); + Assert.That(FenFormatter.Format(position), Is.EqualTo(fen)); Assert.That(new ChessGame(position).ToFen(), Is.EqualTo(fen)); } [Test] public void Fen_LeadingZeroCountersAreCanonicalized() { - Assert.That(FenStrings.TryParse("7k/8/8/8/8/8/8/7K w - - 000 001", out var p), Is.True); - Assert.That(FenStrings.Format(p), Is.EqualTo("7k/8/8/8/8/8/8/7K w - - 0 1")); + Assert.That(FenParser.TryParse("7k/8/8/8/8/8/8/7K w - - 000 001", out var p), Is.True); + Assert.That(FenFormatter.Format(p), Is.EqualTo("7k/8/8/8/8/8/8/7K w - - 0 1")); } [Test] public void InternalPositionEntryPoints_RejectMalformedState() { Assert.Throws(() => new ChessGame(default(Position))); - Assert.Throws(() => FenStrings.Format(default)); + Assert.Throws(() => FenFormatter.Format(default)); Assert.Throws(() => new Position().IsKingChecked()); Assert.Throws(() => Position.CreateDefault().GetPieceAt(64, Colors.White)); Assert.Throws(() => Position.CreateDefault().GetPieceAt(0, 3)); diff --git a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs index 48e1794..89c18fa 100644 --- a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs +++ b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs @@ -10,7 +10,7 @@ internal class PerftTests // Kiwipete's omitted clocks are normalized to 0 1; clocks do not affect perft. private static readonly (string Name, string Fen, ulong[] Nodes)[] Positions = [ - ("Initial", FenStrings.StartPosition, [20, 400, 8902, 197281, 4865609, 119060324]), + ("Initial", ChessGame.StartingFen, [20, 400, 8902, 197281, 4865609, 119060324]), ("Kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", [48, 2039, 97862, 4085603, 193690690]), ("Position3_EnPassant", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", [14, 191, 2812, 43238, 674624, 11030083]), ("Position4_Promotions", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", [6, 264, 9467, 422333, 15833292]), @@ -71,7 +71,7 @@ public void PublicGameApi_MatchesKnownMoveCountsThroughDepthTwo(int index) private static void AssertNodes(string fen, int depth, ulong expected) { - Assert.That(FenStrings.TryParse(fen, out var position), Is.True, fen); + Assert.That(FenParser.TryParse(fen, out var position), Is.True, fen); Assert.That(PerftDriver.Test(position, depth, upper: false).Nodes, Is.EqualTo(expected), $"FEN: {fen}; depth: {depth}"); } @@ -84,7 +84,7 @@ private static void AssertNodes(string fen, int depth, ulong expected) [Category("Perft")] public void SpecialMoveCounts(int index, int depth, int captures, int ep, int castles, int promotions) { - Assert.That(FenStrings.TryParse(Positions[index].Fen, out var position), Is.True); + Assert.That(FenParser.TryParse(Positions[index].Fen, out var position), Is.True); var result = PerftDriver.Test(position, depth, upper: false); Assert.Multiple(() => { diff --git a/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs b/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs index 3600e7f..cb1096d 100644 --- a/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs +++ b/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs @@ -68,7 +68,7 @@ public void Identity_IgnoresBothCounters_EvenBeyondInt64() [TestCase("4k3/8/8/8/3Pp3/8/8/K3R3 b - d3 0 1", false)] public void Identity_RetainsOnlyLegallyAvailableEnPassant(string fen, bool distinct) { - Assert.That(FenStrings.TryParse(fen, out var position), Is.True); + Assert.That(FenParser.TryParse(fen, out var position), Is.True); Span moves = stackalloc int[MoveGen.MaxMoves]; int count = LegalMoves.Write(position, moves); var withTarget = RepetitionKey.Create(position, moves[..count]); @@ -81,8 +81,8 @@ public void Identity_RetainsOnlyLegallyAvailableEnPassant(string fen, bool disti [Test] public void Identity_DistinguishesDifferentLegalEnPassantTargetsOnTheSameBoard() { - Assert.That(FenStrings.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - b6 0 1", out var left), Is.True); - Assert.That(FenStrings.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - d6 0 1", out var right), Is.True); + Assert.That(FenParser.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - b6 0 1", out var left), Is.True); + Assert.That(FenParser.TryParse("7k/8/8/1pPpP3/8/8/8/7K w - d6 0 1", out var right), Is.True); Span moves = stackalloc int[MoveGen.MaxMoves]; int count = LegalMoves.Write(left, moves); var leftKey = RepetitionKey.Create(left, moves[..count]); diff --git a/src/ChessRealms.Engine/ChessGame.History.cs b/src/ChessRealms.Engine/ChessGame.History.cs index 35fcc7e..905a040 100644 --- a/src/ChessRealms.Engine/ChessGame.History.cs +++ b/src/ChessRealms.Engine/ChessGame.History.cs @@ -33,7 +33,7 @@ public bool TryFormatFenAtPly(int ply, Span destination, out int charsWrit { charsWritten = 0; return (uint)ply <= (uint)history.Count - && FenStrings.TryFormatUnchecked(GetPositionAtPly(ply), destination, out charsWritten); + && FenFormatter.TryFormatWithoutValidation(GetPositionAtPly(ply), destination, out charsWritten); } // The caller has checked the range. undo[i] is the position before move i. @@ -47,7 +47,7 @@ private string GetOrCreateFenAfter(int moveIndex) return fen; } - fen = FenStrings.FormatUnchecked(GetPositionAtPly(moveIndex + 1)); + fen = FenFormatter.FormatWithoutValidation(GetPositionAtPly(moveIndex + 1)); history[moveIndex] = entry with { CachedFen = fen }; return fen; } diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 0b0c8b2..187e8b5 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -13,7 +13,7 @@ namespace ChessRealms.Engine; public sealed partial class ChessGame { /// The FEN for the standard starting position. - public const string StartingFen = FenStrings.StartPosition; + public const string StartingFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; private Position position; private readonly string initialFen; @@ -71,7 +71,7 @@ internal ChessGame(Position position) } this.position = position; - initialFen = FenStrings.Format(position); + initialFen = FenFormatter.Format(position); Span legalMoves = stackalloc int[MoveGen.MaxMoves]; int legalMoveCount = LegalMoves.Write(position, legalMoves); currentRepetitionKey = RepetitionKey.Create(position, legalMoves[..legalMoveCount]); @@ -101,7 +101,7 @@ private ChessGame(ChessGame original) /// characters written and leaves the destination unchanged. Move counters have arbitrary precision, /// so there is no fixed maximum FEN length. Also works after the game finishes. public bool TryFormatFen(Span destination, out int charsWritten) - => FenStrings.TryFormatUnchecked(position, destination, out charsWritten); + => FenFormatter.TryFormatWithoutValidation(position, destination, out charsWritten); /// Exports an independent snapshot of the starting position, current move line and draw claim. public GameRecord ExportRecord() => GameRecord.CreateFromGame(this); @@ -399,7 +399,7 @@ private static bool IsBasicDeadPosition(Position position) public static bool TryCreateFromFen(string? fen, [NotNullWhen(true)] out ChessGame? chessGame) { - chessGame = FenStrings.TryParse(fen, out var position) ? new ChessGame(position) : null; + chessGame = FenParser.TryParse(fen, out var position) ? new ChessGame(position) : null; return chessGame is not null; } diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index 655de27..d1662ac 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -67,7 +67,7 @@ internal static ChessGame RestoreGame(GameRecord gameRecord) { ArgumentNullException.ThrowIfNull(gameRecord); - if (!FenStrings.TryParse(gameRecord.InitialFen, out Position position)) + if (!FenParser.TryParse(gameRecord.InitialFen, out Position position)) { throw new FormatException("The game record contains an invalid initial FEN."); } diff --git a/src/ChessRealms.Engine/Parsing/FenFormatter.cs b/src/ChessRealms.Engine/Parsing/FenFormatter.cs new file mode 100644 index 0000000..b110201 --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/FenFormatter.cs @@ -0,0 +1,193 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Types; +using System.Buffers; +using System.Globalization; +using System.Numerics; + +namespace ChessRealms.Engine.Parsing; + +internal static class FenFormatter +{ + // 64 piece symbols, seven rank separators, side, castling and en passant fields. + private const int MaxLengthWithoutCounters = 81; + private const int StackBufferCapacity = 128; + + // Use this entry point when the caller has not validated the position. + internal static string Format(Position position) + { + if (!PositionValidation.IsValid(position)) + { + throw new ArgumentException("Invalid standard chess position.", nameof(position)); + } + + return FormatWithoutValidation(position); + } + + // Skips PositionValidation. Game state has already passed it; geometric + // test fixtures may deliberately omit kings or exceed playable piece counts. + internal static string FormatWithoutValidation(Position position) + { + int capacity = GetMaxFormattedLength(position); + char[]? rented = null; + Span text = capacity <= StackBufferCapacity + ? stackalloc char[StackBufferCapacity] + : (rented = ArrayPool.Shared.Rent(capacity)); + try + { + int written = WriteFenToBuffer(position, text); + return new string(text[..written]); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + // Like FormatWithoutValidation, but preserves the caller's entire span on + // failure. Format into scratch space first, then copy only when it fits. + internal static bool TryFormatWithoutValidation(Position position, Span destination, out int charsWritten) + { + charsWritten = 0; + int capacity = GetMaxFormattedLength(position); + char[]? rented = null; + Span text = capacity <= StackBufferCapacity + ? stackalloc char[StackBufferCapacity] + : (rented = ArrayPool.Shared.Rent(capacity)); + try + { + int written = WriteFenToBuffer(position, text); + if (!text[..written].TryCopyTo(destination)) + { + return false; + } + + charsWritten = written; + return true; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + // An upper bound for scratch allocation, not the actual formatted length. + // Counters are unbounded, so the stack buffer is only the common fast path. + private static int GetMaxFormattedLength(Position position) + => checked(MaxLengthWithoutCounters + 2 + + GetMaxCounterLength(position.halfMoveClock) + + GetMaxCounterLength(position.fullMoveCount)); + + // Since 10 > 2^3, bitLength / 3 + 1 safely bounds the decimal digits. + // This integer bound also works at powers of ten without floating-point rounding. + private static int GetMaxCounterLength(BigInteger value) + => checked((int)(value.GetBitLength() / 3 + 1) + (value.Sign < 0 ? 1 : 0)); + + // The caller provides scratch space sized by GetMaxFormattedLength. + private static int WriteFenToBuffer(Position position, Span destination) + { + int length = WriteFieldsWithoutCounters(position, destination); + destination[length++] = ' '; + length += WriteCounter(position.halfMoveClock, destination[length..]); + destination[length++] = ' '; + length += WriteCounter(position.fullMoveCount, destination[length..]); + return length; + } + + private static int WriteCounter(BigInteger value, Span destination) + { + if (!value.TryFormat(destination, out int written, provider: CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException("The internal FEN buffer is too small."); + } + + return written; + } + + // Writes the first four fields without position validation. En passant is + // preserved as stored; this is not a normalized repetition identity. + internal static string FormatWithoutCounters(Position position) + { + Span text = stackalloc char[MaxLengthWithoutCounters]; + int written = WriteFieldsWithoutCounters(position, text); + return new string(text[..written]); + } + + private static int WriteFieldsWithoutCounters(Position position, Span destination) + { + int length = 0; + for (int rank = 7; rank >= 0; rank--) + { + int emptySquares = 0; + for (int file = 0; file < 8; file++) + { + var piece = position.GetPieceAt(rank * 8 + file, Colors.White); + if (!Piece.IsValid(piece)) + { + piece = position.GetPieceAt(rank * 8 + file, Colors.Black); + } + + if (!Piece.IsValid(piece)) + { + emptySquares++; + continue; + } + + if (emptySquares != 0) + { + destination[length++] = (char)('0' + emptySquares); + emptySquares = 0; + } + + char symbol = "pnbrqk"[piece.Value]; + destination[length++] = piece.Color == Colors.White ? char.ToUpperInvariant(symbol) : symbol; + } + + if (emptySquares != 0) + { + destination[length++] = (char)('0' + emptySquares); + } + + if (rank != 0) + { + destination[length++] = '/'; + } + } + + destination[length++] = ' '; + destination[length++] = position.color == Colors.White ? 'w' : 'b'; + destination[length++] = ' '; + if (position.castlings == Castlings.None) + { + destination[length++] = '-'; + } + else + { + for (int i = 0; i < 4; i++) + { + if ((position.castlings & (1 << i)) != 0) + { + destination[length++] = "KQkq"[i]; + } + } + } + + destination[length++] = ' '; + if (position.enpassant == Squares.Empty) + { + destination[length++] = '-'; + } + else + { + destination[length++] = (char)('a' + position.enpassant % 8); + destination[length++] = (char)('1' + position.enpassant / 8); + } + + return length; + } +} diff --git a/src/ChessRealms.Engine/Parsing/FenParser.cs b/src/ChessRealms.Engine/Parsing/FenParser.cs new file mode 100644 index 0000000..6e50e44 --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/FenParser.cs @@ -0,0 +1,173 @@ +using ChessRealms.Engine.Core.Constants; +using ChessRealms.Engine.Core.Types; +using System.Globalization; +using System.Numerics; + +namespace ChessRealms.Engine.Parsing; + +internal static class FenParser +{ + // Parses all six fields, then checks the standard-chess position invariants. + internal static bool TryParse(string? fen, out Position position) + { + if (TryParseSyntaxOnly(fen, out var candidate) && PositionValidation.IsValid(candidate)) + { + position = candidate; + return true; + } + + position = new(); + return false; + } + + // Internal geometric move-generation fixtures may deliberately omit kings. + // This entry point must never be used to construct a public game. + internal static bool TryParseSyntaxOnly(string? fen, out Position position) + { + position = new(); + if (fen is null) + { + return false; + } + + string[] fields = fen.Split(' '); + if (fields.Length != 6 || fields.Any(string.IsNullOrEmpty)) + { + return false; + } + + Position candidate = new(); + if (!TryParsePiecePlacement(fields[0], ref candidate)) + { + return false; + } + + if (fields[1] is not ("w" or "b")) + { + return false; + } + + candidate.color = fields[1] == "w" ? Colors.White : Colors.Black; + if (!TryParseCastlingRights(fields[2], out candidate.castlings)) + { + return false; + } + + if (!TryParseEnPassantSquare(fields[3], out candidate.enpassant)) + { + return false; + } + + if (!TryParseNonNegativeCounter(fields[4], out candidate.halfMoveClock) + || !TryParseNonNegativeCounter(fields[5], out candidate.fullMoveCount) + || candidate.fullMoveCount == 0) + { + return false; + } + + position = candidate; + return true; + } + + private static bool TryParsePiecePlacement(string text, ref Position position) + { + var ranks = text.Split('/'); + if (ranks.Length != 8) + { + return false; + } + + for (int rank = 0; rank < 8; rank++) + { + int file = 0; + bool previousWasDigit = false; + foreach (char symbol in ranks[rank]) + { + if (symbol is >= '1' and <= '8') + { + if (previousWasDigit) + { + return false; + } + + file += symbol - '0'; + previousWasDigit = true; + } + else + { + int piece = "pnbrqk".IndexOf(char.ToLowerInvariant(symbol)); + if (piece < 0 || !"pnbrqkPNBRQK".Contains(symbol) || file >= 8) + { + return false; + } + + int square = (7 - rank) * 8 + file; + int color = char.IsUpper(symbol) ? Colors.White : Colors.Black; + file++; + position.SetPieceAt(square, piece, color); + previousWasDigit = false; + } + + if (file > 8) + { + return false; + } + } + + if (file != 8) + { + return false; + } + } + + return true; + } + + private static bool TryParseCastlingRights(string text, out int castlingRights) + { + castlingRights = Castlings.None; + if (text == "-") + { + return true; + } + + int lastIndex = -1; + foreach (char symbol in text) + { + int index = "KQkq".IndexOf(symbol); + if (index <= lastIndex) + { + return false; + } + + castlingRights |= 1 << index; + lastIndex = index; + } + + return true; + } + + private static bool TryParseEnPassantSquare(string text, out int square) + { + square = Squares.Empty; + if (text == "-") + { + return true; + } + + if (!Square.TryParse(text, out var enPassant) || text[1] is not ('3' or '6')) + { + return false; + } + + square = enPassant.Index; + return true; + } + + private static bool TryParseNonNegativeCounter(string text, out BigInteger value) + { + value = 0; + return text.All(c => c is >= '0' and <= '9') + && BigInteger.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } +} diff --git a/src/ChessRealms.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs deleted file mode 100644 index f4fdd32..0000000 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ /dev/null @@ -1,349 +0,0 @@ -using ChessRealms.Engine.Core.Constants; -using ChessRealms.Engine.Core.Types; -using System.Buffers; -using System.Globalization; -using System.Numerics; - -namespace ChessRealms.Engine.Parsing; - -internal static class FenStrings -{ - internal const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; - - // 64 piece symbols, seven rank separators, side, castling and en passant fields. - private const int MaxPositionFieldsLength = 81; - private const int StackBufferLength = 128; - - internal static bool TryParse(string? fen, out Position position) - { - if (TryParseSyntax(fen, out var candidate) && PositionValidation.IsValid(candidate)) - { - position = candidate; - return true; - } - - position = new(); - return false; - } - - // Internal geometric move-generation fixtures may deliberately omit kings. - // This entry point must never be used to construct a public game. - internal static bool TryParseSyntax(string? fen, out Position position) - { - position = new(); - if (fen is null) - { - return false; - } - - string[] fields = fen.Split(' '); - if (fields.Length != 6 || fields.Any(string.IsNullOrEmpty)) - { - return false; - } - - Position candidate = new(); - if (!TryParsePiecePlacement(fields[0], ref candidate)) - { - return false; - } - - if (fields[1] is not ("w" or "b")) - { - return false; - } - - candidate.color = fields[1] == "w" ? Colors.White : Colors.Black; - if (!TryParseCastlingRights(fields[2], out candidate.castlings)) - { - return false; - } - - if (!TryParseEnPassantSquare(fields[3], out candidate.enpassant)) - { - return false; - } - - if (!TryParseCounter(fields[4], out candidate.halfMoveClock) - || !TryParseCounter(fields[5], out candidate.fullMoveCount) - || candidate.fullMoveCount == 0) - { - return false; - } - - position = candidate; - return true; - } - - private static bool TryParsePiecePlacement(string text, ref Position position) - { - var ranks = text.Split('/'); - if (ranks.Length != 8) - { - return false; - } - - for (int rank = 0; rank < 8; rank++) - { - int file = 0; - bool previousWasDigit = false; - foreach (char symbol in ranks[rank]) - { - if (symbol is >= '1' and <= '8') - { - if (previousWasDigit) - { - return false; - } - - file += symbol - '0'; - previousWasDigit = true; - } - else - { - int piece = "pnbrqk".IndexOf(char.ToLowerInvariant(symbol)); - if (piece < 0 || !"pnbrqkPNBRQK".Contains(symbol) || file >= 8) - { - return false; - } - - int square = (7 - rank) * 8 + file; - int color = char.IsUpper(symbol) ? Colors.White : Colors.Black; - file++; - position.SetPieceAt(square, piece, color); - previousWasDigit = false; - } - - if (file > 8) - { - return false; - } - } - - if (file != 8) - { - return false; - } - } - - return true; - } - - private static bool TryParseCastlingRights(string text, out int castlingRights) - { - castlingRights = Castlings.None; - if (text == "-") - { - return true; - } - - int lastIndex = -1; - foreach (char symbol in text) - { - int index = "KQkq".IndexOf(symbol); - if (index <= lastIndex) - { - return false; - } - - castlingRights |= 1 << index; - lastIndex = index; - } - - return true; - } - - private static bool TryParseEnPassantSquare(string text, out int square) - { - square = Squares.Empty; - if (text == "-") - { - return true; - } - - if (!Square.TryParse(text, out var enPassant) || text[1] is not ('3' or '6')) - { - return false; - } - - square = enPassant.Index; - return true; - } - - private static bool TryParseCounter(string text, out BigInteger value) - { - value = 0; - return text.All(c => c is >= '0' and <= '9') - && BigInteger.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); - } - - internal static string Format(Position position) - { - if (!PositionValidation.IsValid(position)) - { - throw new ArgumentException("Invalid standard chess position.", nameof(position)); - } - - return FormatUnchecked(position); - } - - internal static string FormatUnchecked(Position position) - { - int capacity = GetBufferLength(position); - char[]? rented = null; - Span text = capacity <= StackBufferLength - ? stackalloc char[StackBufferLength] - : (rented = ArrayPool.Shared.Rent(capacity)); - try - { - int written = WriteUnchecked(position, text); - return new string(text[..written]); - } - finally - { - if (rented is not null) - { - ArrayPool.Shared.Return(rented); - } - } - } - - internal static bool TryFormatUnchecked(Position position, Span destination, out int charsWritten) - { - charsWritten = 0; - int capacity = GetBufferLength(position); - char[]? rented = null; - Span text = capacity <= StackBufferLength - ? stackalloc char[StackBufferLength] - : (rented = ArrayPool.Shared.Rent(capacity)); - try - { - int written = WriteUnchecked(position, text); - if (!text[..written].TryCopyTo(destination)) - { - return false; - } - - charsWritten = written; - return true; - } - finally - { - if (rented is not null) - { - ArrayPool.Shared.Return(rented); - } - } - } - - private static int GetBufferLength(Position position) - => checked(MaxPositionFieldsLength + 2 - + GetCounterBufferLength(position.halfMoveClock) - + GetCounterBufferLength(position.fullMoveCount)); - - // Since 10 > 2^3, bitLength / 3 + 1 safely bounds the decimal digits. - // This integer bound also works at powers of ten without floating-point rounding. - private static int GetCounterBufferLength(BigInteger value) - => checked((int)(value.GetBitLength() / 3 + 1) + (value.Sign < 0 ? 1 : 0)); - - private static int WriteUnchecked(Position position, Span destination) - { - int length = WritePositionFields(position, destination); - destination[length++] = ' '; - length += WriteCounter(position.halfMoveClock, destination[length..]); - destination[length++] = ' '; - length += WriteCounter(position.fullMoveCount, destination[length..]); - return length; - } - - private static int WriteCounter(BigInteger value, Span destination) - { - if (!value.TryFormat(destination, out int written, provider: CultureInfo.InvariantCulture)) - { - throw new InvalidOperationException("The internal FEN buffer is too small."); - } - - return written; - } - - // The first four FEN fields identify a position without its move counters. - internal static string FormatPositionFields(Position position) - { - Span text = stackalloc char[MaxPositionFieldsLength]; - int written = WritePositionFields(position, text); - return new string(text[..written]); - } - - private static int WritePositionFields(Position position, Span destination) - { - int length = 0; - for (int rank = 7; rank >= 0; rank--) - { - int emptySquares = 0; - for (int file = 0; file < 8; file++) - { - var piece = position.GetPieceAt(rank * 8 + file, Colors.White); - if (!Piece.IsValid(piece)) - { - piece = position.GetPieceAt(rank * 8 + file, Colors.Black); - } - - if (!Piece.IsValid(piece)) - { - emptySquares++; - continue; - } - - if (emptySquares != 0) - { - destination[length++] = (char)('0' + emptySquares); - emptySquares = 0; - } - - char symbol = "pnbrqk"[piece.Value]; - destination[length++] = piece.Color == Colors.White ? char.ToUpperInvariant(symbol) : symbol; - } - - if (emptySquares != 0) - { - destination[length++] = (char)('0' + emptySquares); - } - - if (rank != 0) - { - destination[length++] = '/'; - } - } - - destination[length++] = ' '; - destination[length++] = position.color == Colors.White ? 'w' : 'b'; - destination[length++] = ' '; - if (position.castlings == Castlings.None) - { - destination[length++] = '-'; - } - else - { - for (int i = 0; i < 4; i++) - { - if ((position.castlings & (1 << i)) != 0) - { - destination[length++] = "KQkq"[i]; - } - } - } - - destination[length++] = ' '; - if (position.enpassant == Squares.Empty) - { - destination[length++] = '-'; - } - else - { - destination[length++] = (char)('a' + position.enpassant % 8); - destination[length++] = (char)('1' + position.enpassant / 8); - } - - return length; - } -} diff --git a/src/ChessRealms.Engine/Parsing/PgnWriter.cs b/src/ChessRealms.Engine/Parsing/PgnWriter.cs index 176fc01..fd90503 100644 --- a/src/ChessRealms.Engine/Parsing/PgnWriter.cs +++ b/src/ChessRealms.Engine/Parsing/PgnWriter.cs @@ -23,7 +23,7 @@ internal static void Write(PgnGame document, TextWriter writer) } writer.Write('\n'); - if (!FenStrings.TryParse(document.InitialFen, out var position)) + if (!FenParser.TryParse(document.InitialFen, out var position)) { throw new InvalidOperationException("The validated PGN starting position could not be loaded."); } From 3348e90025428786b020aa4e6ba752762a91e148 Mon Sep 17 00:00:00 2001 From: Hash Date: Thu, 22 Oct 2026 23:34:27 +0300 Subject: [PATCH 4/4] feat(pgn): return the validated game during import --- README.md | 3 +- docs/game-rules-api.md | 3 + docs/performance.md | 8 +- docs/pgn.md | 33 +++- scripts/Test-PackageConsumer.ps1 | 30 ++++ .../AllocationProbe.cs | 1 + .../PgnBenchmarks.cs | 14 ++ .../PgnDirectImportTests.cs | 167 ++++++++++++++++++ .../PgnImportTests.cs | 23 +++ src/ChessRealms.Engine/Parsing/PgnReader.cs | 22 ++- src/ChessRealms.Engine/PgnGame.cs | 26 ++- 11 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 src/ChessRealms.Engine.Tests/PgnDirectImportTests.cs diff --git a/README.md b/README.md index fde15ed..ff405cd 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,7 @@ Exchange a single game's tags and mainline in PGN: ```csharp PgnGame document = PgnGame.FromGame(game, result: PgnResult.WhiteWin); string pgn = document.ToPgn(); -PgnGame imported = PgnGame.Parse(pgn); -ChessGame replay = imported.ToGame(); +PgnGame imported = PgnGame.Parse(pgn, out ChessGame replay); ``` `PgnGame` is an immutable snapshot. Its declared result can represent resignation diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 7034af7..b975a1e 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -95,6 +95,9 @@ behavior, examples and performance tradeoffs. PGN model. `ToPgn()` and `WriteTo(TextWriter)` export it; `Parse` and `TryParse` import one game's tags, initial FEN, canonical SAN mainline and declared result. `ToGame()` replays an independent game with history and undo. +`PgnGame.Parse(text, out ChessGame game)` returns the document and the game built +during validation in one replay; the corresponding `TryParse` overload returns +both objects or neither. Later game changes do not affect the document. The declared `PgnResult` is separate from `ChessGame.Outcome`: a decisive result can describe resignation while the position still has legal moves. PGN does not diff --git a/docs/performance.md b/docs/performance.md index 7040b4d..d7beac1 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -18,7 +18,7 @@ Use a single filter to focus on one suite: | `HistoryBenchmarks` | Replay with no FEN reads, per-move FEN/history reads, a final history read, warmed snapshots, cloning, historical FEN and move + undo at 0, 20 and 120 plies. | | `RecordJsonBenchmarks` | Deserialize + restore versus direct JSON restoration, both alone and followed by a move and JSON export, at 0, 20 and 120 plies. | | `SanBenchmarks` | Span/string formatting, parsing and move + undo for opening, ambiguous and mating moves. | -| `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies. | +| `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies, including Parse + ToGame versus import with the validated game. | Setup and input preparation are outside measurements. PGN scenarios use prefixes of the same validated line. Move + undo returns to the same position, with @@ -27,6 +27,12 @@ and collection growth. Export to `TextWriter.Null` excludes output storage, encoding and I/O. Running without arguments starts the existing depth-six perft benchmark, which can take longer. +`ImportAndRestore` and `ImportWithGame` both parse the same input and create the +PGN document and game. The former replays again through `ToGame()`; the latter +returns the game built during validation. Both benchmark methods return the +game and discard the document after parsing. Setup checks matching document +output and game records outside measurements. + History scenarios use prefixes of the same validated PGN line. Replay creates a fresh game per operation. Snapshot and individual historical FEN queries use a game whose strings have already been requested during setup. The move + undo diff --git a/docs/pgn.md b/docs/pgn.md index 6718de5..67cbc90 100644 --- a/docs/pgn.md +++ b/docs/pgn.md @@ -85,9 +85,10 @@ from the mainline under the engine's [rule boundaries](game-rules-api.md). ```csharp ReadOnlySpan input = "1. e4 e5 2. Nf3 *"; -if (PgnGame.TryParse(input, out PgnGame? document, out PgnDiagnostic? error)) +if (PgnGame.TryParse(input, out PgnGame? document, out ChessGame? game, out PgnDiagnostic? error)) { - ChessGame replay = document.ToGame(); + // Both outputs are non-null here. The game was built during PGN validation. + Console.WriteLine($"Declared result: {document.Result}; board: {game.ToFen()}"); } else { @@ -95,9 +96,24 @@ else } ``` +For the throwing form, use `PgnGame.Parse(input, out ChessGame game)`, which +returns the `PgnGame` document. Both overloads replay the mainline once and +return that validated game with history, counters, repetition and undo. The +document owns an immutable snapshot: changing or undoing the returned game +does not change its moves, tags or declared result. As with `ToGame()`, the +declared result does not create a new engine ending. + +Use `PgnGame.Parse(input)` or the three-argument `TryParse` when only the +document is needed. Calling `ToGame()` on it later creates an independent +game through another replay. The overloads returning both objects avoid that +second replay when importing for immediate game use. + `Parse` throws `PgnParseException`, a `FormatException` exposing the same -`Diagnostic`. `TryParse` returns false with a null document on any format, -legality or unsupported-feature error; it never exposes a partly imported game. +`Diagnostic`. All overloads use the same validation and diagnostics. +`TryParse` returns false with a null document and, for the overload returning a +game, a null game on any format, legality or unsupported-feature error. This +includes errors after legal moves or after the termination marker; neither +output exposes a partially imported game. Success returns a null diagnostic. Neither operation takes or mutates an existing game. @@ -133,10 +149,11 @@ and redundant setup tags are normalized on export. ## Costs and validation PGN import consumes SAN from character spans, but processing a document still -allocates model and replay data. `WriteTo` avoids creating the complete output -string. `ToGame()` performs a replay each time, so keep the returned game when -you need to continue using it. Coordinate-based move application pays no PGN -processing cost. +allocates model and replay data. Use the `Parse`/`TryParse` overload returning +the game when both are needed; keep that instance for subsequent operations. +`ToGame()` performs another replay each time. `WriteTo` avoids creating the +complete output string. Coordinate-based move application pays no PGN processing +cost. Export advances one position through the validated mainline, sharing legal-move generation and SAN formatting with `ChessGame`. It does not build another game diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1 index 927b0bf..08be614 100644 --- a/scripts/Test-PackageConsumer.ps1 +++ b/scripts/Test-PackageConsumer.ps1 @@ -180,6 +180,27 @@ if (writer.ToString() != document.ToPgn() throw new InvalidOperationException("The package consumer could not round-trip PGN."); } +var directPgn = PgnGame.Parse(writer.ToString().AsSpan(), out ChessGame pgnReplay); +if (directPgn.ToPgn() != imported.ToPgn() || pgnReplay.ToFen() != game.ToFen() + || pgnReplay.IsFinished || directPgn.Result != PgnResult.WhiteWin + || !PgnGame.TryParse(writer.ToString().AsSpan(), out var tryPgn, out var tryPgnReplay, out var tryPgnError) + || tryPgnError is not null || tryPgn.ToPgn() != directPgn.ToPgn() + || tryPgnReplay.ToFen() != pgnReplay.ToFen()) +{ + throw new InvalidOperationException("The package consumer could not import PGN with its validated game."); +} + +if (!pgnReplay.UndoMove() || directPgn.Moves.Count != 1 || tryPgnReplay.PlyCount != 1) +{ + throw new InvalidOperationException("The direct PGN document and games are not independent."); +} + +if (PgnGame.TryParse("1. e4 * trailing", out var failedPgn, out var failedPgnGame, out var directError) + || failedPgn is not null || failedPgnGame is not null || directError?.Code != PgnErrorCode.UnexpectedToken) +{ + throw new InvalidOperationException("The direct PGN import exposed partial outputs."); +} + if (PgnGame.TryParse("1. e4 {comment} *", out var invalid, out var error) || invalid is not null || error?.Code != PgnErrorCode.UnsupportedAnnotation || error.Offset != 6) { @@ -194,6 +215,15 @@ try catch (PgnParseException exception) when (exception.Diagnostic.Code == PgnErrorCode.InvalidSan) { } + +try +{ + PgnGame.Parse("1. e5 *", out _); + throw new InvalidOperationException("Invalid direct PGN import did not throw."); +} +catch (PgnParseException exception) when (exception.Diagnostic.Code == PgnErrorCode.InvalidSan) +{ +} '@ [System.IO.File]::WriteAllText((Join-Path $consumerRoot "Program.cs"), $program) diff --git a/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs index 7b47ca6..c59da68 100644 --- a/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs +++ b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs @@ -98,6 +98,7 @@ internal static void Run() Measure("PGN", scenario, "ImportSpan", benchmark.ImportSpan); Measure("PGN", scenario, "Restore", benchmark.Restore); Measure("PGN", scenario, "ImportAndRestore", benchmark.ImportAndRestore); + Measure("PGN", scenario, "ImportWithGame", benchmark.ImportWithGame); } } diff --git a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs index 7757a44..4f87051 100644 --- a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs @@ -31,6 +31,13 @@ public void Setup() { throw new InvalidOperationException("The benchmark fixture must round-trip."); } + + var directDocument = PgnGame.Parse(text, out var directGame); + if (directDocument.ToPgn() != imported.ToPgn() + || GameRecord.Serialize(directGame.ExportRecord()) != GameRecord.Serialize(game.ExportRecord())) + { + throw new InvalidOperationException("Both import paths must preserve the document and game."); + } } internal static CoordinateMove[] CreateLongLine() @@ -76,4 +83,11 @@ internal static CoordinateMove[] CreateLongLine() [Benchmark] public ChessGame ImportAndRestore() => PgnGame.Parse(text.AsSpan()).ToGame(); + + [Benchmark] + public ChessGame ImportWithGame() + { + _ = PgnGame.Parse(text.AsSpan(), out var replay); + return replay; + } } diff --git a/src/ChessRealms.Engine.Tests/PgnDirectImportTests.cs b/src/ChessRealms.Engine.Tests/PgnDirectImportTests.cs new file mode 100644 index 0000000..b498821 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/PgnDirectImportTests.cs @@ -0,0 +1,167 @@ +namespace ChessRealms.Engine.Tests; + +internal class PgnDirectImportTests +{ + [TestCase("*")] + [TestCase("1. e4 e5 2. Nf3 Nc6 *")] + [TestCase("1. f3 e5 2. g4 Qh4# 0-1")] + [TestCase("[SetUp \"1\"][FEN \"r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1\"] 1. O-O *")] + [TestCase("[SetUp \"1\"][FEN \"7k/8/8/3pP3/8/8/8/7K w - d6 0 1\"] 1. exd6 *")] + [TestCase("[SetUp \"1\"][FEN \"7k/P7/8/8/8/8/8/7K w - - 0 1\"] 1. a8=N 1/2-1/2")] + [TestCase("[SetUp \"1\"][FEN \"1r5k/P7/8/8/8/8/8/7K w - - 0 1\"] 1. axb8=Q+ *")] + [TestCase("[SetUp \"1\"][FEN \"7k/8/8/8/8/Q1p5/8/Q1Q4K w - - 0 1\"] 1. Qa1xc3+ *")] + [TestCase("[SetUp \"1\"][FEN \"7k/8/8/8/8/8/8/R6K w - - 149 80\"] 80. Ra8+ 1/2-1/2")] + [TestCase("[SetUp \"1\"][FEN \"7k/8/8/8/8/8/8/7K w - - 0 1\"] 1/2-1/2")] + [TestCase("[SetUp \"1\"][FEN \"k7/8/1QK5/8/8/8/8/8 b - - 0 1\"] 1/2-1/2")] + public void ParseWithGame_MatchesSeparateReplayIncludingUndo(string text) + { + var expectedDocument = PgnGame.Parse(text); + var expected = expectedDocument.ToGame(); + var document = PgnGame.Parse(text, out var game); + Assert.That(document.ToPgn(), Is.EqualTo(expectedDocument.ToPgn())); + AssertSameLineThroughUndo(game, expected); + } + + [Test] + public void TryParseWithGame_AcceptsSpanSlicesAndReturnsIndependentDocumentAndGames() + { + const string text = "[Event \"Київ ♟\"][Custom \"value\"] 1. e4 e5 1-0"; + string input = "prefix" + text + "suffix"; + Assert.That(PgnGame.TryParse(input.AsSpan(6, text.Length), out var document, out var game, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(game, Is.Not.Null); + Assert.That(document!.Tags["Event"], Is.EqualTo("Київ ♟")); + Assert.That(document.Tags["Custom"], Is.EqualTo("value")); + string originalPgn = document.ToPgn(); + string originalFen = game!.ToFen(); + var replay = document.ToGame(); + var secondDocument = PgnGame.Parse(text, out var secondGame); + + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.MakeSanMove("c5"), Is.Not.EqualTo(MoveResult.None)); + Assert.That(document.ToPgn(), Is.EqualTo(originalPgn)); + Assert.That(secondDocument.ToPgn(), Is.EqualTo(originalPgn)); + Assert.That(replay.ToFen(), Is.EqualTo(originalFen)); + Assert.That(secondGame.ToFen(), Is.EqualTo(originalFen)); + Assert.That(replay.MakeSanMove("Nf3"), Is.Not.EqualTo(MoveResult.None)); + Assert.That(secondGame.PlyCount, Is.EqualTo(2)); + Assert.That(document.Moves.Count, Is.EqualTo(2)); + Assert.Throws(() => ((IList)document.Moves)[0] = default); + Assert.Throws(() => ((IDictionary)document.Tags)["Event"] = "Changed"); + } + + [TestCase("*", PgnResult.Unknown)] + [TestCase("1-0", PgnResult.WhiteWin)] + [TestCase("0-1", PgnResult.BlackWin)] + [TestCase("1/2-1/2", PgnResult.Draw)] + public void DeclaredResult_DoesNotInventAnEngineEnding(string marker, PgnResult result) + { + Assert.That(PgnGame.TryParse($"[Result \"{marker}\"] 1. e4 {marker}", + out var document, out var game, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(document!.Result, Is.EqualTo(result)); + Assert.That(document.Tags["Result"], Is.EqualTo(marker)); + Assert.That(game!.Outcome, Is.EqualTo(GameOutcome.Ongoing)); + Assert.That(game.DrawIntendedMove, Is.Null); + Assert.That(game.MakeSanMove("e5"), Is.Not.EqualTo(MoveResult.None)); + Assert.That(document.Moves.Count, Is.EqualTo(1)); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(4)] + public void RepetitionAndClaims_AreRebuiltAndIndependentOfTheDocument(int cycles) + { + string text = string.Join(' ', Enumerable.Repeat("Nf3 Nf6 Ng1 Ng8", cycles)) + + (cycles == 4 ? " 1/2-1/2" : " *"); + var document = PgnGame.Parse(text, out var game); + Assert.That(game.RepetitionCount, Is.EqualTo(cycles + 1)); + if (cycles == 2) + { + Assert.That(game.AvailableDrawClaims, Is.EqualTo(DrawClaim.ThreefoldRepetition)); + var claimGame = game.Clone(); + Assert.That(claimGame.ClaimDraw(DrawClaim.ThreefoldRepetition), Is.True); + Assert.That(game.IsFinished, Is.False); + Assert.That(document.Result, Is.EqualTo(PgnResult.Unknown)); + } + else if (cycles == 4) + { + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + } + + AssertSameLineThroughUndo(game, document.ToGame()); + } + + [Test] + [SetCulture("ar-SA")] + public void CustomBlackStart_PreservesLargeCountersAndInitialFen() + { + string number = new('9', 80); + string fen = $"7k/8/8/8/8/8/8/R6K b - - 17 {number}"; + string text = $"[SetUp \"1\"][FEN \"{fen}\"] {number}... Kg8 1{new string('0', 80)}. Ra2 *"; + var document = PgnGame.Parse(text, out var game); + Assert.That(document.InitialFen, Is.EqualTo(fen)); + Assert.That(game.FullmoveNumber, Is.EqualTo(System.Numerics.BigInteger.Pow(10, 80))); + Assert.That(game.HalfmoveClock, Is.EqualTo(new System.Numerics.BigInteger(19))); + AssertSameLineThroughUndo(game, document.ToGame()); + Assert.That(game.ToFen(), Is.EqualTo(fen)); + } + + [TestCase(17)] + [TestCase(12345)] + public void FullGames_PreserveStateThroughUndoAndContinuation(int seed) + { + var expected = new ChessGame(); + var random = new Random(seed); + for (int ply = 0; ply < 120 && !expected.IsFinished; ++ply) + { + var moves = expected.GetLegalMoves(); + expected.MakeMove(moves[random.Next(moves.Count)]); + } + + string text = PgnGame.FromGame(expected).ToPgn(); + var document = PgnGame.Parse(text, out var game); + Assert.That(document.ToPgn(), Is.EqualTo(text)); + AssertSameLineThroughUndo(game, expected); + } + + private static void AssertSameLineThroughUndo(ChessGame actual, ChessGame expected) + { + Assert.That(actual, Is.Not.SameAs(expected)); + Assert.That(actual.History, Is.EqualTo(expected.History)); + while (true) + { + AssertSameState(actual, expected); + bool undone = actual.UndoMove(); + Assert.That(undone, Is.EqualTo(expected.UndoMove())); + if (!undone) + { + break; + } + } + + if (!expected.IsFinished) + { + var move = expected.GetLegalMoves()[0]; + Assert.That(actual.MakeMove(move), Is.EqualTo(expected.MakeMove(move))); + AssertSameState(actual, expected); + Assert.That(actual.UndoMove(), Is.True); + Assert.That(expected.UndoMove(), Is.True); + } + } + + private static void AssertSameState(ChessGame actual, ChessGame expected) + { + 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.PlyCount, Is.EqualTo(expected.PlyCount)); + 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())); + }); + } +} diff --git a/src/ChessRealms.Engine.Tests/PgnImportTests.cs b/src/ChessRealms.Engine.Tests/PgnImportTests.cs index 0340533..860e5f5 100644 --- a/src/ChessRealms.Engine.Tests/PgnImportTests.cs +++ b/src/ChessRealms.Engine.Tests/PgnImportTests.cs @@ -68,6 +68,7 @@ public void Failures_AreAtomicAndHaveStructuredLocations(string text, PgnErrorCo var exception = Assert.Throws(() => PgnGame.Parse(text)); Assert.That(exception!.Diagnostic, Is.EqualTo(error)); Assert.That(exception, Is.InstanceOf()); + AssertDirectFailure(text, error!); } [TestCase("[Event \"a\"][Event \"b\"] *", PgnErrorCode.DuplicateTag)] @@ -92,6 +93,7 @@ public void HeaderFailures_DoNotHavePlyIndex(string text, PgnErrorCode code) Assert.That(error!.Code, Is.EqualTo(code)); Assert.That(error.PlyIndex, Is.Null); Assert.That(error.Offset, Is.InRange(0, text.Length)); + AssertDirectFailure(text, error); } [TestCase("\r\n")] @@ -105,6 +107,7 @@ public void Diagnostic_CountsUtf16OffsetsAndOneBasedLinesAndColumns(string newli Assert.That(error.Line, Is.EqualTo(4)); Assert.That(error.Column, Is.EqualTo(2)); Assert.That(error.PlyIndex, Is.EqualTo(1)); + AssertDirectFailure(text, error); } [Test] @@ -114,6 +117,7 @@ public void ResultTagAndMovetext_MustAgree() Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.ResultMismatch)); Assert.That(error.Offset, Is.EqualTo(21)); Assert.That(error.PlyIndex, Is.EqualTo(1)); + AssertDirectFailure("[Result \"1-0\"] 1. e4 0-1", error); Assert.That(PgnGame.Parse("[Result \"1-0\"] 1. e4 1-0").Result, Is.EqualTo(PgnResult.WhiteWin)); } @@ -139,10 +143,12 @@ public void TagAndTokenLimits_AreEnforcedWithoutTruncation() { Assert.That(PgnGame.TryParse(text, out _, out var error), Is.False); Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.InvalidTag)); + AssertDirectFailure(text, error); } Assert.That(PgnGame.TryParse(new string('a', 256) + " *", out _, out var tokenError), Is.False); Assert.That(tokenError!.Code, Is.EqualTo(PgnErrorCode.UnexpectedToken)); + AssertDirectFailure(new string('a', 256) + " *", tokenError); } [Test] @@ -157,6 +163,23 @@ public void ArbitraryMalformedInput_ReturnsDiagnosticInsteadOfLeakingParserExcep bool success = PgnGame.TryParse(text, out var document, out var error); Assert.That(document is not null, Is.EqualTo(success), text); Assert.That(error is null, Is.EqualTo(success), text); + bool directSuccess = PgnGame.TryParse(text, out var directDocument, out var game, out var directError); + Assert.That(directSuccess, Is.EqualTo(success), text); + Assert.That(directDocument is not null, Is.EqualTo(success), text); + Assert.That(game is not null, Is.EqualTo(success), text); + Assert.That(directError, Is.EqualTo(error), text); } } + + private static void AssertDirectFailure(string text, PgnDiagnostic expected) + { + ChessGame? game = new(); + PgnGame? document = PgnGame.FromGame(game); + Assert.That(PgnGame.TryParse(text, out document, out game, out var diagnostic), Is.False); + Assert.That(document, Is.Null); + Assert.That(game, Is.Null); + Assert.That(diagnostic, Is.EqualTo(expected)); + var exception = Assert.Throws(() => PgnGame.Parse(text, out _)); + Assert.That(exception!.Diagnostic, Is.EqualTo(expected)); + } } diff --git a/src/ChessRealms.Engine/Parsing/PgnReader.cs b/src/ChessRealms.Engine/Parsing/PgnReader.cs index 0ffed6d..8efff27 100644 --- a/src/ChessRealms.Engine/Parsing/PgnReader.cs +++ b/src/ChessRealms.Engine/Parsing/PgnReader.cs @@ -13,17 +13,18 @@ internal ref struct PgnReader(ReadOnlySpan text) private PgnDiagnostic? _error; internal static bool TryRead(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? document, - [NotNullWhen(false)] out PgnDiagnostic? diagnostic) + [NotNullWhen(true)] out ChessGame? game, [NotNullWhen(false)] out PgnDiagnostic? diagnostic) { var reader = new PgnReader(text); - bool success = reader.Read(out document); + bool success = reader.Read(out document, out game); diagnostic = reader._error; return success; } - private bool Read([NotNullWhen(true)] out PgnGame? document) + private bool Read([NotNullWhen(true)] out PgnGame? document, [NotNullWhen(true)] out ChessGame? game) { document = null; + game = null; if (!_text.IsEmpty && _text[0] == '\uFEFF') { ++_position; @@ -51,7 +52,7 @@ private bool Read([NotNullWhen(true)] out PgnGame? document) return Fail(PgnErrorCode.InvalidSetup, offset, null, "FEN requires SetUp \"1\", and SetUp \"1\" requires FEN."); } - if (!ChessGame.TryCreateFromFen(fen ?? ChessGame.StartingFen, out var game)) + if (!ChessGame.TryCreateFromFen(fen ?? ChessGame.StartingFen, out var replay)) { return Fail(PgnErrorCode.InvalidFen, _tagOffsets["FEN"], null, "The starting FEN is invalid."); } @@ -114,7 +115,7 @@ private bool Read([NotNullWhen(true)] out PgnGame? document) } if ((headerResult.HasValue && result != headerResult.Value) - || (game.IsFinished && result != PgnTags.FromOutcome(game.Outcome.Result))) + || (replay.IsFinished && result != PgnTags.FromOutcome(replay.Outcome.Result))) { return Fail(PgnErrorCode.ResultMismatch, start, ply, "The result conflicts with the Result tag or finished position."); } @@ -131,14 +132,17 @@ private bool Read([NotNullWhen(true)] out PgnGame? document) _position, ply, "Only one game is accepted; unexpected content follows its result."); } - document = PgnGame.FromGame(game, _tags, result); + document = PgnGame.FromGame(replay, _tags, result); + // Publish the validated replay only after checking the result + // and trailing input; failures never expose a partial game. + game = replay; return true; } if (IsDigits(token)) { if (pendingNumber.HasValue || !BigInteger.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var number) - || number != game.FullmoveNumber) + || number != replay.FullmoveNumber) { return Fail(PgnErrorCode.InvalidMoveNumber, start, ply, "The move number does not match the position."); } @@ -160,12 +164,12 @@ private bool Read([NotNullWhen(true)] out PgnGame? document) return Fail(PgnErrorCode.UnsupportedAnnotation, _position, ply, "Move annotations are not supported."); } - if (game.IsFinished) + if (replay.IsFinished) { return Fail(PgnErrorCode.MoveAfterGameEnd, start, ply, "No move is legal after the game has finished."); } - if (game.MakeSanMove(token) == MoveResult.None) + if (replay.MakeSanMove(token) == MoveResult.None) { return Fail(PgnErrorCode.InvalidSan, start, ply, "Expected a legal move in canonical SAN for this position."); } diff --git a/src/ChessRealms.Engine/PgnGame.cs b/src/ChessRealms.Engine/PgnGame.cs index 5d96083..e782af4 100644 --- a/src/ChessRealms.Engine/PgnGame.cs +++ b/src/ChessRealms.Engine/PgnGame.cs @@ -113,8 +113,32 @@ public void WriteTo(TextWriter writer) public static PgnGame Parse(ReadOnlySpan text) => TryParse(text, out var game, out var diagnostic) ? game : throw new PgnParseException(diagnostic); + /// Imports a PGN document and returns the game built during validation, replaying the mainline once. + /// The game includes history, counters, repetition and undo. The returned document is an immutable + /// snapshot and is unaffected by later game changes. Its declared result does not apply a new engine ending; + /// an ongoing game can accompany a decisive PGN result, as with . + /// The PGN is invalid or uses unsupported annotations or variants. + public static PgnGame Parse(ReadOnlySpan text, out ChessGame game) + { + if (!TryParse(text, out var document, out var parsedGame, out var diagnostic)) + { + throw new PgnParseException(diagnostic); + } + + game = parsedGame; + return document; + } + /// Imports atomically. On failure returns no game and a diagnostic; on success the diagnostic is null. public static bool TryParse(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? game, [NotNullWhen(false)] out PgnDiagnostic? diagnostic) - => PgnReader.TryRead(text, out game, out diagnostic); + => PgnReader.TryRead(text, out game, out _, out diagnostic); + + /// Imports a document and its game in one mainline replay. On failure both outputs are null; + /// on success both are non-null and the diagnostic is null. + /// The game is exposed only after the entire input is validated. Later game changes do not + /// affect the document. The declared PGN result remains separate from the engine outcome. + public static bool TryParse(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? document, + [NotNullWhen(true)] out ChessGame? game, [NotNullWhen(false)] out PgnDiagnostic? diagnostic) + => PgnReader.TryRead(text, out document, out game, out diagnostic); }