diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 551ca3c..4f18fa5 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -5,6 +5,7 @@ + diff --git a/README.md b/README.md index 58ea648..fca26ad 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ ChessRealms.Engine is a .NET 10 library for standard chess positions and legal play. It uses bitboards and magic attack tables for move generation. The public `ChessGame` API supports FEN import/export, legal moves, promotion, history and -undo, canonical SAN formatting and parsing, JSON game records, and standard +undo, canonical SAN formatting and parsing, mainline PGN import/export, +JSON game records, and standard checkmate, stalemate and supported draw rules. The repository also includes tests, a console game, a perft runner, benchmarks and a magic-number search tool. -It does not provide an AI/search engine, UCI, PGN, Chess960 or tournament +It does not provide an AI/search engine, UCI, annotated PGN, Chess960 or tournament services such as clocks and draw agreements. Dead-position recognition is deliberately incomplete; see the [game API guide](https://github.com/ChessRealms/Engine/blob/main/docs/game-rules-api.md) @@ -31,8 +32,8 @@ The `Deep` test category contains slower perft cases and is opt-in. See the for contributor checks. See the -[performance measurements](https://github.com/ChessRealms/Engine/blob/main/docs/performance.md) -for game API allocation benchmarks, reproduction commands and ownership guarantees. +[benchmark guide](https://github.com/ChessRealms/Engine/blob/main/docs/performance.md) +for core, game API, SAN and PGN timing and allocation measurements. ## Public API @@ -73,6 +74,21 @@ game.MakeSanMove("d5"); // Resolve and play text directly. See the [SAN guide](https://github.com/ChessRealms/Engine/blob/main/docs/san.md) for canonical spelling, state guarantees, historical moves and performance costs. +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` is an immutable snapshot. Its declared result can represent resignation +or a draw agreement without changing the engine's board outcome. Import accepts +canonical SAN and rejects comments, annotations and variations with a structured +diagnostic. See the [PGN guide](https://github.com/ChessRealms/Engine/blob/main/docs/pgn.md) +for result rules, custom starting positions, diagnostics and costs. + Save and restore the current move line, including a successful draw claim: ```csharp diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index a1953b1..3aa0c53 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -55,6 +55,19 @@ search through the current position's legal moves. All SAN operations reject finished games. The [SAN guide](san.md) describes exact notation rules, failure behavior, examples and performance tradeoffs. +## Portable Game Notation + +`PgnGame.FromGame(game, tags, result)` snapshots the current line as an immutable +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. + +The declared `PgnResult` is separate from `ChessGame.Outcome`: a decisive result +can describe resignation while the position still has legal moves. PGN does not +retain a draw claim's reason or unplayed intended move. Use `GameRecord` when +those details must survive restoration. The [PGN guide](pgn.md) covers validation, +structured errors and unsupported comments, NAGs and variations. + ## Game record export and restoration `ExportRecord()` creates an independent, immutable `GameRecord` containing the @@ -206,5 +219,5 @@ fortresses or blocked pawn structures. Lack of a forced mate alone does not end a game. The library supports standard board play, not resignation, draw agreement, -clocks, tournament claim procedures, PGN, Chess960, AI/search, UCI, +clocks, tournament claim procedures, annotated PGN, Chess960, AI/search, UCI, networking or a graphical interface. diff --git a/docs/performance.md b/docs/performance.md index 59c63dd..bd4747f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,76 +1,55 @@ -# Measuring game API allocations +# Running performance benchmarks -`GameApiBenchmarks` measures public operations with an empty game and a game -after 20 plies. Setup, notation parsing and input JSON preparation happen -outside the measured operations. `MakeAndUndoMove` plays one legal move and -undoes it on the same game; warmup has already grown the internal collections. -Restoration and deserialization create a new game each time, including all -validation and replay. Serialization measures an already exported record. - -Run from the repository root: +The benchmark project measures execution time and managed allocations with +BenchmarkDotNet. Run from the repository root after restoring dependencies: ```sh -dotnet build src/ChessRealms.Engine.Benchmark -c Release -dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*GameApiBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/game-api --exporters JSON +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 ``` -The benchmark project also supports the usual BenchmarkDotNet arguments. For a -longer run with isolated benchmark processes, omit `--inProcess` and the short -iteration settings. Running without arguments preserves the existing perft -benchmark. Benchmark artifacts are ignored by Git. +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. | +| `GameApiBenchmarks` | Legal moves, FEN, history, cloning, move + undo, records and JSON at 0 and 20 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. | -## Allocation changes measured on 2026-10-19 +Setup and input preparation are outside measurements. PGN scenarios use prefixes +of the same validated line. Move + undo returns to the same position, with +collections already warmed; restoration creates a new game including replay +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. -The baseline engine was commit `1ea5cc4`, with the same benchmark harness added -before changing the implementation. Both runs used Windows 11 x64, SDK -10.0.402, .NET 10.0.13 and concurrent workstation GC on the same machine. -The command above was used with separate `game-api-before` and `game-api-after` -artifact directories. +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 +drawing conclusions from timing differences. -Allocated managed bytes per operation: +## Allocation cross-check -| Operation | Plies | Before | After | -| --- | ---: | ---: | ---: | -| Get legal moves | 0 | 648 | 208 | -| Read FEN | 0 | 480 | 0 | -| Make and undo a move | 0 | 2776 | 1048 | -| Export record | 0 | 96 | 40 | -| Restore record | 0 | 2728 | 2224 | -| Serialize record | 0 | 664 | 664 | -| Deserialize record | 0 | 3520 | 3016 | -| Get legal moves | 20 | 1056 | 336 | -| Read FEN | 20 | 704 | 0 | -| Make and undo a move | 20 | 3952 | 1416 | -| Export record | 20 | 1176 | 248 | -| Restore record | 20 | 75918 | 40996 | -| Serialize record | 20 | 1224 | 1224 | -| Deserialize record | 20 | 77494 | 42572 | +```sh +dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --allocation-probe +``` -At 20 plies the mean restoration time changed from 145.4 to 100.1 microseconds, -and deserialization from 149.3 to 104.8 microseconds. These are short local -measurements, not production latency guarantees. BenchmarkDotNet could not set -the power plan and reported some iterations below its recommended 100 ms -minimum. Treat timing differences as indicative; FEN access is now close to -measurement overhead, so no timing ratio is claimed for it. +This emits CSV using `GC.GetAllocatedBytesForCurrentThread` around synchronous +operations after warmup. Setup, delegates and output are excluded. It helps +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. -## Implementation and ownership +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 +numbers as current API guarantees. -- Internal legal-move generation filters moves in a bounded, local span. The - public `GetLegalMoves()` still returns an independently owned, read-only - snapshot, including every promotion choice. -- Record export copies coordinate moves directly from the owned history once. - It avoids the public history snapshot and a second move-array copy. The - constructor accepting caller-owned moves still copies them defensively. -- `ToFen()` reuses the initial FEN or the last history entry's `FenAfter`. - Moves already store that string; undo and branching select the correct - history entry. No additional mutable cache is introduced. -- Repetition keys format only the four position fields, excluding counters. - En passant is still included only when a legal en passant move exists. -- `HasLegalMoves` uses the already evaluated outcome: a position without legal - moves has already finished. Claimed and automatic draws also prohibit play. +## API usage -Public history snapshots, game-record immutability, JSON schema version one and -validated restoration keep their existing contracts. JSON serialization itself -is unchanged in this pass. Its allocation count includes the result string and -serializer overhead. Deserialization still validates by replaying; calling -`ChessGame.FromRecord` afterwards performs a second replay. +`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. +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/docs/pgn.md b/docs/pgn.md new file mode 100644 index 0000000..09b85ee --- /dev/null +++ b/docs/pgn.md @@ -0,0 +1,147 @@ +# Portable Game Notation + +PGN is a text exchange format for games. `PgnGame` supports one standard-chess +game with tags, an initial position, the mainline and a declared result. +It uses the existing [SAN API](san.md) to resolve each move against its preceding +position. Board input and engine callers continue using `CoordinateMove`. + +## Model and export + +```csharp +using ChessRealms.Engine; + +var game = new ChessGame(); +game.MakeSanMove("e4"); +game.MakeSanMove("e5"); +game.MakeSanMove("Nf3"); + +var tags = new Dictionary +{ + ["Event"] = "Example", + ["White"] = "Alice", + ["Black"] = "Bob" +}; +PgnGame document = PgnGame.FromGame(game, tags, PgnResult.WhiteWin); +string text = document.ToPgn(); +// ... headers, then: 1. e4 e5 2. Nf3 1-0 + +using var writer = new StreamWriter("game.pgn"); +document.WriteTo(writer); // Does not close or dispose the writer. +``` + +`InitialFen`, `Moves`, `Tags` and `Result` are immutable snapshots. Source tags +are copied; later moves, undo and tag edits do not affect the document. Moves +contain coordinate values, without a cached SAN string or FEN pair per entry. +Only the current line is captured, including only moves played since the +initial FEN. Moves preceding a FEN import cannot be recovered. + +Export uses canonical SAN, invariant move numbers and LF line endings. It +orders the seven roster tags first, then other tags by ordinal name; quote and +backslash characters in values are escaped. Missing roster values default to +`?`, with `????.??.??` for Date and the selected result for Result. Movetext is +wrapped at whitespace to 79 columns, except an individual longer move-number +token. A blank line separates headers and moves and follows the final marker. + +Custom starting positions generate `SetUp "1"` and `FEN` automatically, using +the initial position rather than the final board. A line starting with Black +uses the FEN's fullmove number followed by three periods. Counters beyond +`Int64` are preserved. Redundant starting-position FEN/SetUp tags are removed. +Supplied setup tags must form a valid pair and match the game's initial FEN. + +Tag names are case sensitive: 1–255 ASCII letters, digits or underscores, with +a letter or digit first. Values may contain at most 255 UTF-16 code units; +controls, line separators and unpaired surrogates are rejected. Printable +Unicode values are supported as an extension to traditional PGN character +encoding. Use UTF-8 when writing files that contain them. Nonstructural metadata, +including dates, player names and custom tags, is preserved without semantic +interpretation. Duplicate tag names are rejected. + +## Result rules + +`PgnResult` maps `Unknown` to `*`, `WhiteWin` to `1-0`, `BlackWin` to `0-1` and +`Draw` to `1/2-1/2`. Unknown covers both ongoing games and an unavailable result. + +| Source of result | Behavior | +| --- | --- | +| `FromGame` without an explicit result or Result tag | Uses `ChessGame.Outcome`. | +| Supplied Result tag | Uses that result; an explicit argument must agree. | +| Unfinished engine position | Any declared result is accepted, including resignation, timeout or an agreed draw. | +| Finished engine game | The result must match its outcome, including a successful draw claim. | +| Import | The mandatory final marker must agree with the Result tag, if present, and any ending established by replay. | + +`ToGame()` returns an independent replay with board, counters, history, +repetition and undo. It does not apply the declared result as a new engine +ending. For example, the exported `1-0` above produces an ongoing engine game +because the moves alone do not establish White's win. Applications displaying +a match should retain `PgnGame.Result` as well as the replayed board. + +A claimed draw exports as `1/2-1/2`, but PGN does not preserve its claim reason or +unplayed intended move. Replaying such a mainline may therefore produce an +ongoing engine game. For exact persistence of claims, use `GameRecord` and its +versioned JSON format. Natural endings and automatic draws are reconstructed +from the mainline under the engine's [rule boundaries](game-rules-api.md). + +## Import and diagnostics + +```csharp +ReadOnlySpan input = "1. e4 e5 2. Nf3 *"; +if (PgnGame.TryParse(input, out PgnGame? document, out PgnDiagnostic? error)) +{ + ChessGame replay = document.ToGame(); +} +else +{ + Console.WriteLine($"{error.Code} at {error.Line}:{error.Column}: {error.Message}"); +} +``` + +`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. +Success returns a null diagnostic. Neither operation takes or mutates an +existing game. + +Diagnostic offsets count zero-based UTF-16 code units relative to the supplied +span. Line and column are one-based; CRLF counts as one newline, and a tab as +one column. `PlyIndex` is the next zero-based mainline index, or null for header +errors. Codes distinguish invalid tags, duplicate tags, setup/FEN errors, +result mismatches, wrong move numbers, illegal/noncanonical SAN, moves after +an engine ending, missing termination and unsupported input. + +Import accepts optional headers, an initial BOM, ASCII space/tab/newline/vertical +tab separators, and optional move numbers. Supplied numbers must match the +position; import allows zero or more periods with whitespace between them. +Output normalizes this layout. Only canonical SAN is accepted: required check +and mate suffixes, uppercase `O` for castling, explicit promotion and minimal +legal disambiguation. Tags use only `\"` and `\\` escapes. + +The following inputs are explicitly rejected: + +- Comments (`{...}` or `;`), NAGs (`$1`), annotation suffixes (`!`, `?!`, etc.) + and recursive variations (`(...)`), including after the final result. +- Escape lines (`%`), reserved angle-bracket constructs and extra content after + the game. Multiple games must be separated by the caller using a suitable PGN + database reader; this API does not provide one. +- Nonstandard variants. A supplied Variant tag must be exactly `Standard`. +- Missing result markers, malformed tags and unplayable mainlines. + +No annotations or variations are silently discarded. This is a defined subset +of the [PGN specification](https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm), +not a lossless editor for arbitrary PGN databases. Original whitespace, tag order +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. + +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 +consumer smoke test exercises PGN through the built NuGet artifact. + +See the [benchmark guide](performance.md) to measure import, export, snapshots +and restoration. diff --git a/docs/san.md b/docs/san.md index ec62ade..287fc7b 100644 --- a/docs/san.md +++ b/docs/san.md @@ -2,8 +2,8 @@ SAN describes a move relative to the position **before** that move. It is useful for a human-readable move list, text entry and as a building block for PGN. -This API handles one canonical SAN move at a time; it does not import or export -PGN documents. +The SAN methods handle one canonical move at a time. [PgnGame](pgn.md) builds on +them to import and export a single game's tags and mainline. `CoordinateMove` remains the move value used by board input, history and game records. A drag from e2 to e4 already supplies a coordinate move and needs no SAN @@ -74,8 +74,8 @@ accepted; `Ngf3`, `Ng1f3`, `nf3`, `Nf3+` and `Nf3!` are rejected. There is no whitespace trimming, `0-0` alias, omitted capture marker, implicit promotion, `e.p.` suffix, figurine notation, move number, result token, comment -or annotation support. This is a strict SAN API, not a permissive PGN reader. -PGN import normalization can be designed separately. +or annotation support. PGN import handles move numbers and result markers +separately and retains the same canonical SAN requirement. ## Failure and state guarantees @@ -126,64 +126,18 @@ A draw claim's intended move was not played and is not in `record.Moves`. A game loaded from FEN only has the history played since that FEN. Formatting historical moves does not recover earlier moves or encode a game's result. -## Implementation and costs - -SAN queries generate legal encoded moves into a bounded stack buffer. The -parser reads the input span, filters candidates by the supplied move fields, -then formats only the single selected candidate into a seven-character buffer -to verify canonical spelling. It does not format every legal move. - -Check/mate detection applies the selected move to a value copy of `Position` -without updating counters. It generates opponent replies only if that copy is -in check. It does not clone game history, build FEN, update repetition keys or -use public `GetLegalMoves()` snapshots. Mate detection uses board replies rather -than the public move availability of a game ended by a draw. +## Performance and validation After initialization, successful `TryParseSan`, `ParseSan` and `TryFormatSan` queries require no managed heap allocations. `FormatSan` allocates the returned -string; throwing APIs allocate exceptions on failure. SAN still costs legal-move -generation and sometimes reply generation, so zero allocation does not imply -zero CPU cost. - -`MakeSanMove` avoids a second current-position search, but its canonical suffix -verification is additional work. For checking moves, reply generation is done -both for SAN verification and by normal move application. Applying a move still -has the existing FEN/history/repetition allocations. Coordinate-based callers +string; throwing APIs allocate exceptions on failure. SAN still requires legal +move generation and check/mate detection. + +`MakeSanMove` reuses the resolved internal move for application. Applying it +still has the normal game history and repetition costs. Coordinate-based callers pay no SAN formatting or parsing cost. `SanTests` covers canonical fixtures, strict rejection, state preservation, undo, deterministic playout round trips and allocation checks after warmup. -`SanBenchmarks` compares span/string formatting, parsing, coordinate application, -parse-then-apply and direct SAN application. Run it with: - -```sh -dotnet run --project src/ChessRealms.Engine.Benchmark -c Release -- --filter '*SanBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/san --exporters JSON -``` - -## Local measurements on 2026-10-20 - -The command above was run against this implementation on Windows 11 x64, -SDK 10.0.402 and .NET 10.0.13. All 18 benchmarks completed. Span formatting and -parsing allocated **0 B** in all three scenarios; string formatting allocated -**32 B** for the selected two-to-five-character outputs. Mean span formatting -times ranged from 0.616 to 1.252 microseconds; parsing from 0.734 to 1.306 -microseconds. - -Application measurements include undo to return to the same starting position. -Setup and warmup occur outside measurement, including collection growth. - -| Scenario | Coordinate + undo | Parse + coordinate + undo | Direct SAN + undo | Allocated by each application path | -| --- | ---: | ---: | ---: | ---: | -| Opening (`e4`) | 4.940 us | 5.796 us | 4.885 us | 1048 B | -| Ambiguous (`Nb1d2`) | 3.588 us | 4.355 us | 3.782 us | 720 B | -| Mate (`Qg7#`) | 4.397 us | 5.803 us | 4.801 us | 584 B | - -These are short in-process measurements, not latency guarantees. Processor -metadata was unavailable through CIM, the benchmark could not set the power -plan, and some iterations were below the recommended 100 ms minimum. Small -differences, including the opening direct-SAN result versus coordinate input, -are within measurement variability. Use longer isolated-process runs for -performance decisions. Reports are written under `artifacts/benchmarks/san/`. - -Existing FEN history storage, JSON records and package versioning are unchanged -by the SAN feature. +`SanBenchmarks` compares formatting, parsing and application paths; see the +[benchmark guide](performance.md) for commands and measurement guidance. diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1 index 8346935..fe13d43 100644 --- a/scripts/Test-PackageConsumer.ps1 +++ b/scripts/Test-PackageConsumer.ps1 @@ -123,6 +123,36 @@ if (game.MakeSanMove("e4+") != MoveResult.None || game.MakeSanMove("e4") == Move { throw new InvalidOperationException("The package consumer could not apply and undo canonical SAN."); } + +game.MakeSanMove("e4"); +var document = PgnGame.FromGame(game, + new Dictionary { ["Event"] = "Package smoke test" }, PgnResult.WhiteWin); +using var writer = new StringWriter(); +document.WriteTo(writer); +if (writer.ToString() != document.ToPgn() + || !PgnGame.TryParse(writer.ToString().AsSpan(), out var imported, out var diagnostic) + || diagnostic is not null || imported.Result != PgnResult.WhiteWin + || imported.Tags["Event"] != "Package smoke test" + || imported.Moves.Count != 1 || imported.ToGame().ToFen() != game.ToFen() + || imported.ToGame().IsFinished) +{ + throw new InvalidOperationException("The package consumer could not round-trip PGN."); +} + +if (PgnGame.TryParse("1. e4 {comment} *", out var invalid, out var error) + || invalid is not null || error?.Code != PgnErrorCode.UnsupportedAnnotation || error.Offset != 6) +{ + throw new InvalidOperationException("The package consumer could not inspect a PGN diagnostic."); +} + +try +{ + PgnGame.Parse("1. e5 *"); + throw new InvalidOperationException("Invalid PGN 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 new file mode 100644 index 0000000..05f2f5f --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs @@ -0,0 +1,86 @@ +using System.Globalization; + +namespace ChessRealms.Engine.Benchmark; + +// Complements BenchmarkDotNet's process-wide memory diagnoser. All work is synchronous; +// delegates, fixture setup, output and result consumption are outside the measured region. +internal static class AllocationProbe +{ + internal static void Run() + { + Console.WriteLine("Suite,Scenario,Operation,BytesPerOperation"); + foreach (string scenario in new[] { "Start", "Kiwipete" }) + { + var benchmark = new CoreBenchmarks { Scenario = scenario }; + benchmark.Setup(); + Measure("Core", scenario, "GeneratePseudoLegalMoves", benchmark.GeneratePseudoLegalMoves); + Measure("Core", scenario, "PerftDepth3", benchmark.PerftDepth3); + Measure("Core", scenario, "FormatFen", benchmark.FormatFen); + } + + foreach (int plies in new[] { 0, 20 }) + { + var benchmark = new GameApiBenchmarks { MoveCount = plies }; + benchmark.Setup(); + string scenario = plies.ToString(CultureInfo.InvariantCulture); + Measure("GameApi", scenario, "LegalMoves", benchmark.LegalMoves); + Measure("GameApi", scenario, "Fen", benchmark.Fen); + Measure("GameApi", scenario, "HistorySnapshot", benchmark.HistorySnapshot); + Measure("GameApi", scenario, "Clone", benchmark.Clone); + Measure("GameApi", scenario, "MakeAndUndoMove", benchmark.MakeAndUndoMove); + Measure("GameApi", scenario, "ExportRecord", benchmark.ExportRecord); + Measure("GameApi", scenario, "RestoreRecord", benchmark.RestoreRecord); + Measure("GameApi", scenario, "Serialize", benchmark.Serialize); + Measure("GameApi", scenario, "Deserialize", benchmark.Deserialize); + } + + foreach (string scenario in new[] { "Opening", "Ambiguous", "Mate" }) + { + var benchmark = new SanBenchmarks { Scenario = scenario }; + benchmark.Setup(); + Measure("SAN", scenario, "FormatString", benchmark.FormatString); + Measure("SAN", scenario, "FormatSpan", benchmark.FormatSpan); + Measure("SAN", scenario, "ParseSpan", benchmark.ParseSpan); + Measure("SAN", scenario, "CoordinateMakeAndUndo", benchmark.CoordinateMakeAndUndo); + Measure("SAN", scenario, "ParseMakeAndUndo", benchmark.ParseMakeAndUndo); + Measure("SAN", scenario, "SanMakeAndUndo", benchmark.SanMakeAndUndo); + } + + foreach (int plies in new[] { 0, 20, 120 }) + { + var benchmark = new PgnBenchmarks { MoveCount = plies }; + benchmark.Setup(); + string scenario = plies.ToString(CultureInfo.InvariantCulture); + Measure("PGN", scenario, "Snapshot", benchmark.Snapshot); + Measure("PGN", scenario, "ExportString", benchmark.ExportString); + Measure("PGN", scenario, "ExportToNullWriter", () => + { + benchmark.ExportToNullWriter(); + return 0; + }); + Measure("PGN", scenario, "ImportSpan", benchmark.ImportSpan); + Measure("PGN", scenario, "Restore", benchmark.Restore); + Measure("PGN", scenario, "ImportAndRestore", benchmark.ImportAndRestore); + } + } + + private static void Measure(string suite, string scenario, string operation, Func action) + { + T? last = default; + for (int i = 0; i < 32; ++i) + { + last = action(); + } + + const int samples = 128; + long start = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < samples; ++i) + { + last = action(); + } + + long bytes = GC.GetAllocatedBytesForCurrentThread() - start; + GC.KeepAlive(last); + Console.WriteLine($"{suite},{scenario},{operation},{(bytes / (double)samples).ToString(CultureInfo.InvariantCulture)}"); + } +} diff --git a/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs new file mode 100644 index 0000000..ea61d08 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs @@ -0,0 +1,42 @@ +using BenchmarkDotNet.Attributes; +using ChessRealms.Engine.Core.Movements; +using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class CoreBenchmarks +{ + private Position position; + + [Params("Start", "Kiwipete")] + public string Scenario { get; set; } = "Start"; + + [GlobalSetup] + public void Setup() + { + string fen = Scenario == "Start" ? ChessGame.StartingFen + : "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; + position = ChessGame.FromFen(fen).Position; + ulong expected = Scenario == "Start" ? 8902UL : 97862UL; + if (Perft.PerftDriver.Test(position, 3, false).Nodes != expected) + { + throw new InvalidOperationException("Invalid perft benchmark fixture."); + } + } + + [Benchmark] + public int GeneratePseudoLegalMoves() + { + var copy = position; + Span moves = stackalloc int[MoveGen.MaxMoves]; + return MoveGen.WriteMoves(ref copy, copy.color, moves); + } + + [Benchmark] + public ulong PerftDepth3() => Perft.PerftDriver.Test(position, 3, false).Nodes; + + [Benchmark] + public string FormatFen() => FenStrings.FormatUnchecked(position); +} diff --git a/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs index 04eadb2..edf5423 100644 --- a/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs +++ b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs @@ -38,6 +38,12 @@ record = game.ExportRecord(); [Benchmark] public string Fen() => game.ToFen(); + [Benchmark] + public IReadOnlyList HistorySnapshot() => game.History; + + [Benchmark] + public ChessGame Clone() => game.Clone(); + [Benchmark] public MoveResult MakeAndUndoMove() { diff --git a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs new file mode 100644 index 0000000..dc3dc49 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs @@ -0,0 +1,79 @@ +using BenchmarkDotNet.Attributes; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class PgnBenchmarks +{ + private ChessGame game = null!; + private PgnGame document = null!; + private string text = null!; + + [Params(0, 20, 120)] + public int MoveCount { get; set; } + + [GlobalSetup] + public void Setup() + { + game = new ChessGame(); + foreach (var move in CreateLongLine().Take(MoveCount)) + { + if (game.MakeMove(move) == MoveResult.None) + { + throw new InvalidOperationException("The benchmark fixture must contain every requested ply."); + } + } + + document = PgnGame.FromGame(game); + text = document.ToPgn(); + var imported = PgnGame.Parse(text); + if (imported.Moves.Count != MoveCount || imported.ToGame().ToFen() != game.ToFen()) + { + throw new InvalidOperationException("The benchmark fixture must round-trip."); + } + } + + private 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. + for (int seed = 0; seed < 128; ++seed) + { + var candidate = new ChessGame(); + var random = new Random(seed); + int plies = 0; + while (plies < 120 && !candidate.IsFinished) + { + var moves = candidate.GetLegalMoves(); + candidate.MakeMove(moves[random.Next(moves.Count)]); + ++plies; + } + + if (plies == 120) + { + return candidate.CopyRecordedMoves(); + } + } + + throw new InvalidOperationException("Could not construct a 120-ply benchmark fixture."); + } + + [Benchmark] + public PgnGame Snapshot() => PgnGame.FromGame(game); + + [Benchmark] + public string ExportString() => document.ToPgn(); + + // Measures traversal/formatting without output buffering, encoding or I/O. + [Benchmark] + public void ExportToNullWriter() => document.WriteTo(TextWriter.Null); + + [Benchmark] + public PgnGame ImportSpan() => PgnGame.Parse(text.AsSpan()); + + [Benchmark] + public ChessGame Restore() => document.ToGame(); + + [Benchmark] + public ChessGame ImportAndRestore() => PgnGame.Parse(text.AsSpan()).ToGame(); +} diff --git a/src/ChessRealms.Engine.Benchmark/Program.cs b/src/ChessRealms.Engine.Benchmark/Program.cs index 577279a..16614d6 100644 --- a/src/ChessRealms.Engine.Benchmark/Program.cs +++ b/src/ChessRealms.Engine.Benchmark/Program.cs @@ -4,6 +4,12 @@ using BenchmarkDotNet.Toolchains.InProcess.Emit; using ChessRealms.Engine.Benchmark; +if (args is ["--allocation-probe"]) +{ + AllocationProbe.Run(); + return; +} + Console.WriteLine("ChessRealms benchmarks"); Console.WriteLine(); diff --git a/src/ChessRealms.Engine.Tests/PgnExportTests.cs b/src/ChessRealms.Engine.Tests/PgnExportTests.cs new file mode 100644 index 0000000..1bea770 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/PgnExportTests.cs @@ -0,0 +1,121 @@ +using System.Globalization; + +namespace ChessRealms.Engine.Tests; + +internal class PgnExportTests +{ + [Test] + public void StartingGame_ExportsCanonicalRosterAndTermination() + { + const string expected = "[Event \"?\"]\n[Site \"?\"]\n[Date \"????.??.??\"]\n[Round \"?\"]\n" + + "[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n*\n\n"; + Assert.That(PgnGame.FromGame(new ChessGame()).ToPgn(), Is.EqualTo(expected)); + } + + [Test] + public void Tags_AreEscapedAndSortedOrdinally_AndWriterRemainsOpen() + { + var tags = new Dictionary + { + ["z"] = "last", ["A"] = "first", ["Event"] = "Київ \"Open\" \\ 2026 ♟", + ["event"] = "case sensitive", ["Variant"] = "Standard" + }; + var document = PgnGame.FromGame(new ChessGame(), tags); + using var writer = new StringWriter { NewLine = "\r\n" }; + document.WriteTo(writer); + string text = writer.ToString(); + Assert.That(text, Is.EqualTo(document.ToPgn())); + Assert.That(text, Does.StartWith("[Event \"Київ \\\"Open\\\" \\\\ 2026 ♟\"]\n")); + Assert.That(text, Does.Contain("[A \"first\"]\n[Variant \"Standard\"]\n[event \"case sensitive\"]\n[z \"last\"]")); + Assert.That(text, Does.Not.Contain("\r")); + Assert.That(PgnGame.Parse(text).Tags, Is.EquivalentTo(document.Tags)); + writer.Write("still open"); + Assert.That(writer.ToString(), Does.EndWith("still open")); + } + + [Test] + public void BlackStartingPosition_UsesFenCountersBeyondInt64_AndInvariantFormatting() + { + const string number = "999999999999999999999999"; + var game = ChessGame.FromFen($"7k/8/8/8/8/8/8/R6K b - - 0 {number}"); + game.MakeSanMove("Kg8"); + game.MakeSanMove("Ra2"); + var culture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA"); + var document = PgnGame.FromGame(game); + string text = document.ToPgn(); + Assert.That(text, Does.Contain($"[FEN \"7k/8/8/8/8/8/8/R6K b - - 0 {number}\"]\n[SetUp \"1\"]")); + Assert.That(text, Does.EndWith($"{number}... Kg8 1000000000000000000000000. Ra2 *\n\n")); + Assert.That(PgnGame.Parse(text).ToGame().ToFen(), Is.EqualTo(game.ToFen())); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + + [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")] + [TestCase("1r5k/P7/8/8/8/8/8/7K w - - 0 1", "axb8=Q+", "*")] + [TestCase("7k/8/8/8/8/Q1p5/8/Q1Q4K w - - 0 1", "Qa1xc3+", "*")] + [TestCase("7k/5Q2/6K1/8/8/8/8/8 w - - 149 80", "Qg7#", "1-0")] + [TestCase("7k/8/8/8/8/8/8/R6K w - - 149 80", "Ra8+", "1/2-1/2")] + public void SpecialMovesAndEndings_RoundTrip(string fen, string san, string result) + { + var game = ChessGame.FromFen(fen); + Assert.That(game.MakeSanMove(san), Is.Not.EqualTo(MoveResult.None)); + var document = PgnGame.FromGame(game); + string text = document.ToPgn(); + Assert.That(text, Does.EndWith($". {san} {result}\n\n")); + var replay = PgnGame.Parse(text).ToGame(); + Assert.That(replay.ToFen(), Is.EqualTo(game.ToFen())); + Assert.That(replay.Outcome, Is.EqualTo(game.Outcome)); + Assert.That(replay.History, Is.EqualTo(game.History)); + } + + [TestCase(17)] + [TestCase(12345)] + public void FullGames_RoundTripMovesHistoryRepetitionAndUndo_AndWrapMovetext(int seed) + { + var game = new ChessGame(); + var random = new Random(seed); + for (int i = 0; i < 160 && !game.IsFinished; ++i) + { + var moves = game.GetLegalMoves(); + game.MakeMove(moves[random.Next(moves.Count)]); + } + + var document = PgnGame.FromGame(game); + string text = document.ToPgn(); + string movetext = text[(text.IndexOf("\n\n", StringComparison.Ordinal) + 2)..]; + Assert.That(movetext.Split('\n').All(line => line.Length <= 79 && line == line.Trim()), Is.True); + Assert.That(movetext.Count(c => c == '\n'), Is.GreaterThan(2)); + var imported = PgnGame.Parse(text); + Assert.That(imported.ToPgn(), Is.EqualTo(text)); + Assert.That(imported.Moves, Is.EqualTo(document.Moves)); + var replay = imported.ToGame(); + Assert.That(replay.History, Is.EqualTo(game.History)); + do + { + Assert.That(replay.ToFen(), Is.EqualTo(game.ToFen())); + Assert.That(replay.Outcome, Is.EqualTo(game.Outcome)); + Assert.That(replay.RepetitionCount, Is.EqualTo(game.RepetitionCount)); + Assert.That(replay.AvailableDrawClaims, Is.EqualTo(game.AvailableDrawClaims)); + Assert.That(replay.UndoMove(), Is.EqualTo(game.UndoMove())); + } while (game.History.Count != 0); + } + + [Test] + public void FivefoldRepetition_IsPreservedByMainlineReplay() + { + string line = string.Join(" ", Enumerable.Repeat("Nf3 Nf6 Ng1 Ng8", 4)) + " 1/2-1/2"; + var document = PgnGame.Parse(line); + Assert.That(document.ToGame().Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + Assert.That(PgnGame.Parse(document.ToPgn()).ToGame().Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + Assert.That(PgnGame.TryParse(line.Replace("1/2-1/2", "Nf3 1/2-1/2"), out _, out var error), Is.False); + Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.MoveAfterGameEnd)); + } +} diff --git a/src/ChessRealms.Engine.Tests/PgnImportTests.cs b/src/ChessRealms.Engine.Tests/PgnImportTests.cs new file mode 100644 index 0000000..0340533 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/PgnImportTests.cs @@ -0,0 +1,162 @@ +namespace ChessRealms.Engine.Tests; + +internal class PgnImportTests +{ + [TestCase("e4 e5 Nf3 Nc6 *")] + [TestCase("1.e4 e5 2.Nf3 Nc6 *")] + [TestCase("1. e4 1... e5 2. Nf3 2... Nc6 *")] + [TestCase("1 e4 1 e5 2 Nf3 2 Nc6 *")] + [TestCase("\uFEFF \t\r\n1 . . e4\n1. . . e5\r2. Nf3\tNc6 *\v")] + [TestCase("[Event\"Example\"][Result\"*\"]1.e4 e5 2.Nf3 Nc6*")] + public void ImportLayoutVariants_ResolveSameMoves(string text) + { + var document = PgnGame.Parse(text); + Assert.That(document.Moves, Is.EqualTo(new[] { "e2e4", "e7e5", "g1f3", "b8c6" }.Select(move => CoordinateMove.Parse(move)))); + Assert.That(document.Result, Is.EqualTo(PgnResult.Unknown)); + Assert.That(document.Tags.Count, Is.EqualTo(7)); + } + + [Test] + public void SpanSlice_UsesOnlySuppliedInput() + { + ReadOnlySpan input = "prefix1. e4 *suffix"; + Assert.That(PgnGame.TryParse(input.Slice(6, 7), out var document, out var diagnostic), Is.True); + Assert.That(diagnostic, Is.Null); + Assert.That(document!.Moves.Count, Is.EqualTo(1)); + } + + [TestCase("", PgnErrorCode.MissingResult, 0, 0)] + [TestCase("1. e4", PgnErrorCode.MissingResult, 5, 1)] + [TestCase("2. e4 *", PgnErrorCode.InvalidMoveNumber, 0, 0)] + [TestCase("1. *", PgnErrorCode.InvalidMoveNumber, 0, 0)] + [TestCase("1. 1. e4 *", PgnErrorCode.InvalidMoveNumber, 3, 0)] + [TestCase("1. e5 *", PgnErrorCode.InvalidSan, 3, 0)] + [TestCase("1. e4 e4 *", PgnErrorCode.InvalidSan, 6, 1)] + [TestCase("1. e4+ *", PgnErrorCode.InvalidSan, 3, 0)] + [TestCase("1. Ng1f3 *", PgnErrorCode.InvalidSan, 3, 0)] + [TestCase("1. e4 \0 *", PgnErrorCode.UnexpectedToken, 6, 1)] + [TestCase("1. e4 \f *", PgnErrorCode.UnexpectedToken, 6, 1)] + [TestCase("1. e4 \u00a0 *", PgnErrorCode.UnexpectedToken, 6, 1)] + [TestCase("1. e4 ! *", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("1. e4!? *", PgnErrorCode.UnsupportedAnnotation, 5, 0)] + [TestCase("1. e4 {comment} *", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("1. e4 ;comment\n*", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("1. e4 $1 *", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("1. e4 (1. d4) *", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("1. e4 ) *", PgnErrorCode.UnsupportedAnnotation, 6, 1)] + [TestCase("%escape\n*", PgnErrorCode.UnsupportedConstruct, 0, 0)] + [TestCase(" *", PgnErrorCode.UnsupportedConstruct, 0, 0)] + [TestCase("* {comment}", PgnErrorCode.UnsupportedAnnotation, 2, 0)] + [TestCase("* [Event \"second\"] *", PgnErrorCode.MultipleGames, 2, 0)] + [TestCase("* 1. e4 *", PgnErrorCode.UnexpectedToken, 2, 0)] + [TestCase("* *", PgnErrorCode.UnexpectedToken, 2, 0)] + [TestCase("1. f3 e5 2. g4 Qh4# *", PgnErrorCode.ResultMismatch, 20, 4)] + [TestCase("1. f3 e5 2. g4 Qh4# 1-0", PgnErrorCode.ResultMismatch, 20, 4)] + [TestCase("1. f3 e5 2. g4 Qh4# 3. e4 0-1", PgnErrorCode.MoveAfterGameEnd, 23, 4)] + public void Failures_AreAtomicAndHaveStructuredLocations(string text, PgnErrorCode code, int offset, int ply) + { + Assert.That(PgnGame.TryParse(text, out var document, out var error), Is.False); + Assert.That(document, Is.Null); + Assert.That(error, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(error!.Code, Is.EqualTo(code)); + Assert.That(error.Offset, Is.EqualTo(offset)); + Assert.That(error.PlyIndex, Is.EqualTo(ply)); + Assert.That(error.Message, Is.Not.Empty); + }); + var exception = Assert.Throws(() => PgnGame.Parse(text)); + Assert.That(exception!.Diagnostic, Is.EqualTo(error)); + Assert.That(exception, Is.InstanceOf()); + } + + [TestCase("[Event \"a\"][Event \"b\"] *", PgnErrorCode.DuplicateTag)] + [TestCase("[_Event \"a\"] *", PgnErrorCode.InvalidTag)] + [TestCase("[Event bare] *", PgnErrorCode.InvalidTag)] + [TestCase("[Event \"a\\n\"] *", PgnErrorCode.InvalidTag)] + [TestCase("[Event \"a\nb\"] *", PgnErrorCode.InvalidTag)] + [TestCase("[Event \"a\"", PgnErrorCode.InvalidTag)] + [TestCase("[Event \"a", PgnErrorCode.InvalidTag)] + [TestCase("[Event \"a\" extra] *", PgnErrorCode.InvalidTag)] + [TestCase("[Result \"win\"] *", PgnErrorCode.InvalidResult)] + [TestCase("[Variant \"Chess960\"] *", PgnErrorCode.UnsupportedVariant)] + [TestCase("[SetUp \"1\"] *", PgnErrorCode.InvalidSetup)] + [TestCase("[SetUp \"2\"] *", PgnErrorCode.InvalidSetup)] + [TestCase("[FEN \"invalid\"] *", PgnErrorCode.InvalidSetup)] + [TestCase("[SetUp \"0\"][FEN \"invalid\"] *", PgnErrorCode.InvalidSetup)] + [TestCase("[SetUp \"1\"][FEN \"invalid\"] *", PgnErrorCode.InvalidFen)] + public void HeaderFailures_DoNotHavePlyIndex(string text, PgnErrorCode code) + { + Assert.That(PgnGame.TryParse(text, out var document, out var error), Is.False); + Assert.That(document, Is.Null); + Assert.That(error!.Code, Is.EqualTo(code)); + Assert.That(error.PlyIndex, Is.Null); + Assert.That(error.Offset, Is.InRange(0, text.Length)); + } + + [TestCase("\r\n")] + [TestCase("\r")] + [TestCase("\n")] + public void Diagnostic_CountsUtf16OffsetsAndOneBasedLinesAndColumns(string newline) + { + string text = $"[Event \"Київ ♟\"]{newline}{newline}1. e4{newline}\te4 *"; + Assert.That(PgnGame.TryParse(text, out _, out var error), Is.False); + Assert.That(error!.Offset, Is.EqualTo(text.LastIndexOf("e4", StringComparison.Ordinal))); + Assert.That(error.Line, Is.EqualTo(4)); + Assert.That(error.Column, Is.EqualTo(2)); + Assert.That(error.PlyIndex, Is.EqualTo(1)); + } + + [Test] + public void ResultTagAndMovetext_MustAgree() + { + Assert.That(PgnGame.TryParse("[Result \"1-0\"] 1. e4 0-1", out _, out var error), Is.False); + Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.ResultMismatch)); + Assert.That(error.Offset, Is.EqualTo(21)); + Assert.That(error.PlyIndex, Is.EqualTo(1)); + Assert.That(PgnGame.Parse("[Result \"1-0\"] 1. e4 1-0").Result, Is.EqualTo(PgnResult.WhiteWin)); + } + + [Test] + public void TerminalStartingFen_RequiresItsResult() + { + const string header = "[SetUp \"1\"][FEN \"7k/8/8/8/8/8/8/7K w - - 0 1\"] "; + Assert.That(PgnGame.TryParse(header + "*", out _, out var error), Is.False); + Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.ResultMismatch)); + Assert.That(PgnGame.Parse(header + "1/2-1/2").ToGame().IsFinished, Is.True); + Assert.That(PgnGame.TryParse(header + "1. Kg1 1/2-1/2", out _, out error), Is.False); + Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.MoveAfterGameEnd)); + } + + [Test] + public void TagAndTokenLimits_AreEnforcedWithoutTruncation() + { + string max = new('a', 255); + var valid = PgnGame.Parse($"[{max} \"{max}\"] *"); + Assert.That(valid.Tags[max], Is.EqualTo(max)); + string[] invalid = [$"[{max}a \"v\"] *", $"[Name \"{max}a\"] *", "[Name \"\ud800\"] *"]; + foreach (string text in invalid) + { + Assert.That(PgnGame.TryParse(text, out _, out var error), Is.False); + Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.InvalidTag)); + } + + Assert.That(PgnGame.TryParse(new string('a', 256) + " *", out _, out var tokenError), Is.False); + Assert.That(tokenError!.Code, Is.EqualTo(PgnErrorCode.UnexpectedToken)); + } + + [Test] + public void ArbitraryMalformedInput_ReturnsDiagnosticInsteadOfLeakingParserExceptions() + { + var random = new Random(2048); + const string alphabet = "[]\"\\{}();!?$*0123456789.e4x+#_ \t\r\n\uFEFF\ud800"; + for (int sample = 0; sample < 500; ++sample) + { + string text = new(Enumerable.Range(0, random.Next(1, 80)) + .Select(_ => alphabet[random.Next(alphabet.Length)]).ToArray()); + 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); + } + } +} diff --git a/src/ChessRealms.Engine.Tests/PgnModelTests.cs b/src/ChessRealms.Engine.Tests/PgnModelTests.cs new file mode 100644 index 0000000..76f464b --- /dev/null +++ b/src/ChessRealms.Engine.Tests/PgnModelTests.cs @@ -0,0 +1,135 @@ +namespace ChessRealms.Engine.Tests; + +internal class PgnModelTests +{ + [Test] + public void Snapshot_OwnsTagsAndCurrentLine_AndReplaysIndependentGames() + { + var game = new ChessGame(); + game.MakeSanMove("e4"); + game.MakeSanMove("e5"); + game.UndoMove(); + var tags = new Dictionary { ["Event"] = "Example", ["Custom"] = "value" }; + var document = PgnGame.FromGame(game, tags, PgnResult.WhiteWin); + tags["Event"] = "Changed"; + game.MakeSanMove("c5"); + var replay = document.ToGame(); + var secondReplay = document.ToGame(); + + Assert.Multiple(() => + { + Assert.That(document.InitialFen, Is.EqualTo(ChessGame.StartingFen)); + Assert.That(document.Moves, Is.EqualTo(new[] { CoordinateMove.Parse("e2e4") })); + Assert.That(document.Tags["Event"], Is.EqualTo("Example")); + Assert.That(document.Tags["Date"], Is.EqualTo("????.??.??")); + Assert.That(document.Tags["Result"], Is.EqualTo("1-0")); + Assert.That(document.Result, Is.EqualTo(PgnResult.WhiteWin)); + Assert.That(replay.Outcome.Result, Is.EqualTo(GameResult.Ongoing)); + }); + Assert.Throws(() => ((IList)document.Moves)[0] = default); + Assert.Throws(() => ((IDictionary)document.Tags)["Event"] = "Changed"); + Assert.That(replay.UndoMove(), Is.True); + Assert.That(secondReplay.History.Count, Is.EqualTo(1)); + Assert.That(game.History.Count, Is.EqualTo(2)); + Assert.That(PgnGame.Parse(document.ToPgn()).ToPgn(), Is.EqualTo(document.ToPgn())); + } + + [TestCase("*", PgnResult.Unknown)] + [TestCase("1-0", PgnResult.WhiteWin)] + [TestCase("0-1", PgnResult.BlackWin)] + [TestCase("1/2-1/2", PgnResult.Draw)] + public void UnfinishedPosition_AllowsDeclaredResults(string marker, PgnResult result) + { + var game = new ChessGame(); + var document = PgnGame.FromGame(game, new Dictionary { ["Result"] = marker }); + Assert.That(document.Result, Is.EqualTo(result)); + Assert.That(document.ToPgn(), Does.EndWith($"\n\n{marker}\n\n")); + Assert.That(document.ToGame().IsFinished, Is.False); + Assert.That(game.IsFinished, Is.False); + } + + [TestCase("bad name", "text")] + [TestCase("_name", "text")] + [TestCase("", "text")] + [TestCase("Name", "line\nbreak")] + [TestCase("Name", "tab\t")] + [TestCase("Name", "\u007f")] + [TestCase("Name", "\u2028")] + [TestCase("Result", "draw")] + [TestCase("Variant", "Chess960")] + [TestCase("SetUp", "1")] + [TestCase("SetUp", "2")] + [TestCase("FEN", ChessGame.StartingFen)] + public void InvalidTags_AreRejected(string name, string value) + { + Assert.Throws(() => + PgnGame.FromGame(new ChessGame(), new Dictionary { [name] = value })); + } + + [Test] + public void DuplicateNullOversizedAndMalformedUnicodeTags_AreRejected() + { + KeyValuePair[] duplicate = [new("Event", "a"), new("Event", "b")]; + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), duplicate)); + KeyValuePair[] nullName = [new(null!, "a")]; + KeyValuePair[] nullValue = [new("Name", null!)]; + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), nullName)); + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), nullValue)); + foreach (var pair in new[] { new KeyValuePair(new string('a', 256), "v"), + new("Name", new string('a', 256)), new("Name", "\ud800") }) + { + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), new[] { pair })); + } + } + + [Test] + public void InvalidArgumentsAndConflictingResults_AreRejected() + { + Assert.Throws(() => PgnGame.FromGame(null!)); + Assert.Throws(() => PgnGame.FromGame(new ChessGame()).WriteTo(null!)); + Assert.Throws(() => new PgnParseException(null!)); + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), result: (PgnResult)99)); + Assert.Throws(() => PgnGame.FromGame(new ChessGame(), + new Dictionary { ["Result"] = "1-0" }, PgnResult.BlackWin)); + var mate = PgnGame.Parse("1. f3 e5 2. g4 Qh4# 0-1").ToGame(); + Assert.That(PgnGame.FromGame(mate).Result, Is.EqualTo(PgnResult.BlackWin)); + Assert.Throws(() => PgnGame.FromGame(mate, result: PgnResult.Unknown)); + Assert.Throws(() => PgnGame.FromGame(mate, result: PgnResult.WhiteWin)); + } + + [Test] + public void SetupTags_MustMatchInitialPosition_AndNormalizeRedundantSetup() + { + const string fen = "7k/8/8/8/8/8/8/R6K b - - 0 23"; + var custom = ChessGame.FromFen(fen); + custom.MakeSanMove("Kg8"); + var document = PgnGame.FromGame(custom); + Assert.That(document.InitialFen, Is.EqualTo(fen)); + Assert.That(document.Tags["FEN"], Is.EqualTo(fen)); + Assert.That(document.Tags["SetUp"], Is.EqualTo("1")); + var tags = new Dictionary { ["SetUp"] = "1", ["FEN"] = ChessGame.StartingFen }; + Assert.Throws(() => PgnGame.FromGame(custom, tags)); + Assert.That(PgnGame.FromGame(new ChessGame(), tags).Tags.ContainsKey("FEN"), Is.False); + tags["FEN"] = "invalid"; + Assert.Throws(() => PgnGame.FromGame(custom, tags)); + Assert.Throws(() => + PgnGame.FromGame(custom, new Dictionary { ["SetUp"] = "0" })); + Assert.That(PgnGame.FromGame(new ChessGame(), + new Dictionary { ["SetUp"] = "0" }).Tags.ContainsKey("SetUp"), Is.False); + } + + [Test] + public void DrawClaim_ResultSurvives_ButReasonAndUnplayedIntendedMoveAreNotInvented() + { + var game = ChessGame.FromFen("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1a2")), Is.True); + var document = PgnGame.FromGame(game); + var imported = PgnGame.Parse(document.ToPgn()); + Assert.That(imported.Result, Is.EqualTo(PgnResult.Draw)); + Assert.That(imported.Moves, Is.Empty); + Assert.That(imported.ToGame().Outcome.Result, Is.EqualTo(GameResult.Ongoing)); + Assert.That(game.DrawIntendedMove, Is.EqualTo(CoordinateMove.Parse("a1a2"))); + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FiftyMoveRule)); + Assert.Throws(() => PgnGame.FromGame(game, result: PgnResult.WhiteWin)); + } +} diff --git a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs index 94b338d..0922c6b 100644 --- a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs +++ b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs @@ -56,6 +56,11 @@ public void ExportedTypes_AreTheReviewedRootSurface() "ChessRealms.Engine.GameState", "ChessRealms.Engine.MoveHistoryEntry", "ChessRealms.Engine.MoveResult", + "ChessRealms.Engine.PgnDiagnostic", + "ChessRealms.Engine.PgnErrorCode", + "ChessRealms.Engine.PgnGame", + "ChessRealms.Engine.PgnParseException", + "ChessRealms.Engine.PgnResult", "ChessRealms.Engine.PieceColor", "ChessRealms.Engine.PieceValue", "ChessRealms.Engine.Square" diff --git a/src/ChessRealms.Engine/Parsing/PgnReader.cs b/src/ChessRealms.Engine/Parsing/PgnReader.cs new file mode 100644 index 0000000..0ffed6d --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/PgnReader.cs @@ -0,0 +1,326 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; + +namespace ChessRealms.Engine.Parsing; + +internal ref struct PgnReader(ReadOnlySpan text) +{ + private readonly ReadOnlySpan _text = text; + private readonly Dictionary _tags = new(StringComparer.Ordinal); + private readonly Dictionary _tagOffsets = new(StringComparer.Ordinal); + private int _position; + private PgnDiagnostic? _error; + + internal static bool TryRead(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? document, + [NotNullWhen(false)] out PgnDiagnostic? diagnostic) + { + var reader = new PgnReader(text); + bool success = reader.Read(out document); + diagnostic = reader._error; + return success; + } + + private bool Read([NotNullWhen(true)] out PgnGame? document) + { + document = null; + if (!_text.IsEmpty && _text[0] == '\uFEFF') + { + ++_position; + } + + SkipWhiteSpace(); + while (_position < _text.Length && _text[_position] == '[') + { + if (!ReadTag()) + { + return false; + } + + SkipWhiteSpace(); + } + + if (_tags.TryGetValue("Variant", out var variant) && variant != "Standard") + { + return Fail(PgnErrorCode.UnsupportedVariant, _tagOffsets["Variant"], null, "Only standard chess is supported."); + } + + if (!PgnTags.TryReadSetup(_tags, out var fen)) + { + int offset = _tagOffsets.GetValueOrDefault("SetUp", _tagOffsets.GetValueOrDefault("FEN")); + return Fail(PgnErrorCode.InvalidSetup, offset, null, "FEN requires SetUp \"1\", and SetUp \"1\" requires FEN."); + } + + if (!ChessGame.TryCreateFromFen(fen ?? ChessGame.StartingFen, out var game)) + { + return Fail(PgnErrorCode.InvalidFen, _tagOffsets["FEN"], null, "The starting FEN is invalid."); + } + + PgnResult? headerResult = null; + if (_tags.TryGetValue("Result", out var marker)) + { + if (!PgnTags.TryReadResult(marker, out var result)) + { + return Fail(PgnErrorCode.InvalidResult, _tagOffsets["Result"], null, "Invalid Result tag."); + } + + headerResult = result; + } + + int ply = 0; + int? pendingNumber = null; + while (true) + { + SkipWhiteSpace(); + if (_position == _text.Length) + { + return Fail(PgnErrorCode.MissingResult, _position, ply, "A game termination marker is required."); + } + + int start = _position; + if (RejectUnsupported(ply)) + { + return false; + } + + if (_text[_position] == '*') + { + ++_position; + } + else + { + while (_position < _text.Length && IsSymbolCharacter(_text[_position])) + { + ++_position; + } + } + + var token = _text[start.._position]; + if (token.IsEmpty) + { + return Fail(PgnErrorCode.UnexpectedToken, start, ply, "Expected SAN, a move number or a result."); + } + + if (token.Length > 255) + { + return Fail(PgnErrorCode.UnexpectedToken, start, ply, "A PGN symbol cannot exceed 255 characters."); + } + + if (PgnTags.TryReadResult(token, out var result)) + { + if (pendingNumber.HasValue) + { + return Fail(PgnErrorCode.InvalidMoveNumber, pendingNumber.Value, ply, "A move number must precede a move."); + } + + if ((headerResult.HasValue && result != headerResult.Value) + || (game.IsFinished && result != PgnTags.FromOutcome(game.Outcome.Result))) + { + return Fail(PgnErrorCode.ResultMismatch, start, ply, "The result conflicts with the Result tag or finished position."); + } + + SkipWhiteSpace(); + if (_position < _text.Length) + { + if (RejectUnsupported(ply)) + { + return false; + } + + return Fail(_text[_position] == '[' ? PgnErrorCode.MultipleGames : PgnErrorCode.UnexpectedToken, + _position, ply, "Only one game is accepted; unexpected content follows its result."); + } + + document = PgnGame.FromGame(game, _tags, result); + return true; + } + + if (IsDigits(token)) + { + if (pendingNumber.HasValue || !BigInteger.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var number) + || number != game.FullmoveNumber) + { + return Fail(PgnErrorCode.InvalidMoveNumber, start, ply, "The move number does not match the position."); + } + + pendingNumber = start; + SkipWhiteSpace(); + // PGN import permits zero or more periods, separated by whitespace. + while (_position < _text.Length && _text[_position] == '.') + { + ++_position; + SkipWhiteSpace(); + } + + continue; + } + + if (_position < _text.Length && _text[_position] is '!' or '?') + { + return Fail(PgnErrorCode.UnsupportedAnnotation, _position, ply, "Move annotations are not supported."); + } + + if (game.IsFinished) + { + return Fail(PgnErrorCode.MoveAfterGameEnd, start, ply, "No move is legal after the game has finished."); + } + + if (game.MakeSanMove(token) == MoveResult.None) + { + return Fail(PgnErrorCode.InvalidSan, start, ply, "Expected a legal move in canonical SAN for this position."); + } + + pendingNumber = null; + ++ply; + } + } + + private bool ReadTag() + { + int tagStart = _position++; + SkipWhiteSpace(); + int nameStart = _position; + while (_position < _text.Length && (char.IsAsciiLetterOrDigit(_text[_position]) || _text[_position] == '_')) + { + ++_position; + } + + int nameLength = _position - nameStart; + if (nameLength is 0 or > 255 || !char.IsAsciiLetterOrDigit(_text[nameStart])) + { + return Fail(PgnErrorCode.InvalidTag, nameStart, null, "Invalid tag name."); + } + + string name = _text.Slice(nameStart, nameLength).ToString(); + if (_tags.ContainsKey(name)) + { + return Fail(PgnErrorCode.DuplicateTag, nameStart, null, "Tag names must be unique."); + } + + SkipWhiteSpace(); + if (_position == _text.Length || _text[_position] != '"') + { + return Fail(PgnErrorCode.InvalidTag, _position, null, "Expected a quoted tag value."); + } + + int valueStart = ++_position; + Span value = stackalloc char[255]; + int length = 0; + while (_position < _text.Length && _text[_position] != '"') + { + int characterStart = _position; + char c = _text[_position++]; + if (c == '\\') + { + if (_position == _text.Length || _text[_position] is not ('\\' or '"')) + { + return Fail(PgnErrorCode.InvalidTag, characterStart, null, "Only quote and backslash escapes are supported."); + } + + c = _text[_position++]; + } + + if (length == value.Length || char.IsControl(c) || c is '\u2028' or '\u2029') + { + return Fail(PgnErrorCode.InvalidTag, characterStart, null, "Tag values must contain at most 255 printable UTF-16 characters."); + } + + value[length++] = c; + } + + if (_position == _text.Length) + { + return Fail(PgnErrorCode.InvalidTag, _position, null, "Unterminated tag value."); + } + + string decoded = value[..length].ToString(); + if (!PgnTags.IsValidValue(decoded)) + { + return Fail(PgnErrorCode.InvalidTag, valueStart, null, "Invalid Unicode in tag value."); + } + + ++_position; + SkipWhiteSpace(); + if (_position == _text.Length || _text[_position] != ']') + { + return Fail(PgnErrorCode.InvalidTag, _position, null, "Expected the closing tag bracket."); + } + + ++_position; + _tags.Add(name, decoded); + _tagOffsets.Add(name, tagStart); + return true; + } + + private bool RejectUnsupported(int ply) + { + char c = _text[_position]; + if (c is '{' or '}' or ';' or '$' or '(' or ')' or '!' or '?') + { + Fail(PgnErrorCode.UnsupportedAnnotation, _position, ply, "Comments, NAGs and variations are not supported."); + return true; + } + + if (c is '%' or '<' or '>') + { + Fail(PgnErrorCode.UnsupportedConstruct, _position, ply, "PGN escape lines and reserved constructs are not supported."); + return true; + } + + return false; + } + + private void SkipWhiteSpace() + { + while (_position < _text.Length && _text[_position] is ' ' or '\t' or '\r' or '\n' or '\v') + { + ++_position; + } + } + + private static bool IsSymbolCharacter(char c) => char.IsAsciiLetterOrDigit(c) || c is '_' or '+' or '#' or '=' or ':' or '-' or '/'; + + private static bool IsDigits(ReadOnlySpan token) + { + foreach (char c in token) + { + if (!char.IsAsciiDigit(c)) + { + return false; + } + } + + return true; + } + + private bool Fail(PgnErrorCode code, int offset, int? ply, string message) + { + int line = 1; + int column = 1; + for (int i = 0; i < offset; ++i) + { + if (_text[i] == '\r') + { + if (i + 1 < offset && _text[i + 1] == '\n') + { + ++i; + } + + ++line; + column = 1; + } + else if (_text[i] == '\n') + { + ++line; + column = 1; + } + else + { + ++column; + } + } + + _error = new PgnDiagnostic(code, offset, line, column, ply, message); + return false; + } +} diff --git a/src/ChessRealms.Engine/Parsing/PgnTags.cs b/src/ChessRealms.Engine/Parsing/PgnTags.cs new file mode 100644 index 0000000..2757f88 --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/PgnTags.cs @@ -0,0 +1,126 @@ +namespace ChessRealms.Engine.Parsing; + +internal static class PgnTags +{ + internal static readonly string[] Roster = ["Event", "Site", "Date", "Round", "White", "Black", "Result"]; + + internal static bool IsValidName(string? name) + { + if (string.IsNullOrEmpty(name) || name.Length > 255 || !char.IsAsciiLetterOrDigit(name[0])) + { + return false; + } + + foreach (char c in name) + { + if (!char.IsAsciiLetterOrDigit(c) && c != '_') + { + return false; + } + } + + return true; + } + + internal static bool IsValidValue(string? value) + { + if (value is null || value.Length > 255) + { + return false; + } + + for (int i = 0; i < value.Length; ++i) + { + char c = value[i]; + if (char.IsControl(c) || c is '\u2028' or '\u2029') + { + return false; + } + + if (char.IsSurrogate(c)) + { + if (!char.IsHighSurrogate(c) || i + 1 == value.Length || !char.IsLowSurrogate(value[++i])) + { + return false; + } + } + } + + return true; + } + + internal static bool TryReadSetup(IReadOnlyDictionary tags, out string? fen) + { + bool hasFen = tags.TryGetValue("FEN", out fen); + bool hasSetup = tags.TryGetValue("SetUp", out var setup); + return hasFen ? hasSetup && setup == "1" : !hasSetup || setup == "0"; + } + + internal static Dictionary Normalize(Dictionary tags, string initialFen, PgnResult result) + { + var normalized = new Dictionary(tags, StringComparer.Ordinal); + foreach (string name in Roster) + { + normalized.TryAdd(name, name == "Date" ? "????.??.??" : "?"); + } + + normalized["Result"] = ResultText(result); + if (initialFen != ChessGame.StartingFen) + { + normalized["SetUp"] = "1"; + normalized["FEN"] = initialFen; + } + else + { + normalized.Remove("SetUp"); + normalized.Remove("FEN"); + } + + return normalized; + } + + internal static string ResultText(PgnResult result) => result switch + { + PgnResult.WhiteWin => "1-0", + PgnResult.BlackWin => "0-1", + PgnResult.Draw => "1/2-1/2", + _ => "*" + }; + + internal static PgnResult FromOutcome(GameResult result) => result switch + { + GameResult.WhiteWin => PgnResult.WhiteWin, + GameResult.BlackWin => PgnResult.BlackWin, + GameResult.Draw => PgnResult.Draw, + _ => PgnResult.Unknown + }; + + internal static bool TryReadResult(ReadOnlySpan text, out PgnResult result) + { + result = PgnResult.Unknown; + if (text.SequenceEqual("*")) + { + return true; + } + + if (text.SequenceEqual("1-0")) + { + result = PgnResult.WhiteWin; + return true; + } + + if (text.SequenceEqual("0-1")) + { + result = PgnResult.BlackWin; + return true; + } + + if (text.SequenceEqual("1/2-1/2")) + { + result = PgnResult.Draw; + return true; + } + + return false; + } +} diff --git a/src/ChessRealms.Engine/Parsing/PgnWriter.cs b/src/ChessRealms.Engine/Parsing/PgnWriter.cs new file mode 100644 index 0000000..77331ab --- /dev/null +++ b/src/ChessRealms.Engine/Parsing/PgnWriter.cs @@ -0,0 +1,86 @@ +using System.Globalization; + +namespace ChessRealms.Engine.Parsing; + +internal static class PgnWriter +{ + internal static void Write(PgnGame document, TextWriter writer) + { + foreach (string name in PgnTags.Roster) + { + WriteTag(writer, name, document.Tags[name]); + } + + foreach (var pair in document.Tags.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (!PgnTags.Roster.Contains(pair.Key, StringComparer.Ordinal)) + { + WriteTag(writer, pair.Key, pair.Value); + } + } + + writer.Write('\n'); + var replay = ChessGame.FromFen(document.InitialFen); + int column = 0; + Span san = stackalloc char[7]; + for (int i = 0; i < document.Moves.Count; ++i) + { + if (replay.CurrentColor == PieceColor.White || i == 0) + { + string number = replay.FullmoveNumber.ToString(CultureInfo.InvariantCulture) + + (replay.CurrentColor == PieceColor.White ? "." : "..."); + WriteToken(writer, number, ref column); + } + + CoordinateMove move = document.Moves[i]; + if (!replay.TryFormatSan(move, san, out int written)) + { + throw new InvalidOperationException("The validated PGN line could not be replayed."); + } + + WriteToken(writer, san[..written], ref column); + replay.MakeMove(move); + } + + WriteToken(writer, PgnTags.ResultText(document.Result), ref column); + writer.Write("\n\n"); + } + + private static void WriteTag(TextWriter writer, string name, string value) + { + writer.Write('['); + writer.Write(name); + writer.Write(" \""); + foreach (char c in value) + { + if (c is '\\' or '"') + { + writer.Write('\\'); + } + + writer.Write(c); + } + + writer.Write("\"]\n"); + } + + private static void WriteToken(TextWriter writer, ReadOnlySpan token, ref int column) + { + if (column != 0) + { + if (column + 1 + token.Length > 79) + { + writer.Write('\n'); + column = 0; + } + else + { + writer.Write(' '); + ++column; + } + } + + writer.Write(token); + column += token.Length; + } +} diff --git a/src/ChessRealms.Engine/PgnDiagnostic.cs b/src/ChessRealms.Engine/PgnDiagnostic.cs new file mode 100644 index 0000000..411e9e9 --- /dev/null +++ b/src/ChessRealms.Engine/PgnDiagnostic.cs @@ -0,0 +1,43 @@ +namespace ChessRealms.Engine; + +/// The category of a PGN import failure. +public enum PgnErrorCode +{ + UnexpectedToken, + InvalidTag, + DuplicateTag, + InvalidSetup, + InvalidFen, + InvalidResult, + ResultMismatch, + InvalidMoveNumber, + InvalidSan, + MoveAfterGameEnd, + MissingResult, + UnsupportedAnnotation, + UnsupportedConstruct, + UnsupportedVariant, + MultipleGames +} + +/// A PGN error location. Offset is zero-based UTF-16; line and column are one-based. +/// PlyIndex is the zero-based next mainline ply, or null for header errors. +public sealed record PgnDiagnostic(PgnErrorCode Code, int Offset, int Line, int Column, int? PlyIndex, string Message); + +/// A failed PGN import with a structured diagnostic. +public sealed class PgnParseException : FormatException +{ + public PgnDiagnostic Diagnostic { get; } + + public PgnParseException(PgnDiagnostic diagnostic) + : base(Describe(diagnostic)) + { + Diagnostic = diagnostic; + } + + private static string Describe(PgnDiagnostic diagnostic) + { + ArgumentNullException.ThrowIfNull(diagnostic); + return $"{diagnostic.Message} (line {diagnostic.Line}, column {diagnostic.Column}, offset {diagnostic.Offset})."; + } +} diff --git a/src/ChessRealms.Engine/PgnGame.cs b/src/ChessRealms.Engine/PgnGame.cs new file mode 100644 index 0000000..5d96083 --- /dev/null +++ b/src/ChessRealms.Engine/PgnGame.cs @@ -0,0 +1,120 @@ +using ChessRealms.Engine.Parsing; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace ChessRealms.Engine; + +/// An immutable single-game PGN mainline, with tags and a declared result. +public sealed class PgnGame +{ + public string InitialFen { get; } + public IReadOnlyList Moves { get; } + public IReadOnlyDictionary Tags { get; } + public PgnResult Result { get; } + + private PgnGame(ChessGame game, Dictionary tags, PgnResult result) + { + InitialFen = game.InitialFen; + Moves = Array.AsReadOnly(game.CopyRecordedMoves()); + Result = result; + Tags = new ReadOnlyDictionary(PgnTags.Normalize(tags, InitialFen, result)); + } + + /// Snapshots a game's current line. Supplied tags are copied; missing roster tags get defaults. + /// Result defaults to the supplied Result tag, or the game's outcome. An explicit result must + /// agree with a supplied Result tag and any finished engine outcome. Declared results do not modify the game. + /// PGN does not preserve the reason or intended move of a draw claim; use GameRecord for that purpose. + /// The game is null. + /// A tag, result, or starting-position declaration is invalid or conflicting. + public static PgnGame FromGame(ChessGame game, IEnumerable>? tags = null, + PgnResult? result = null) + { + ArgumentNullException.ThrowIfNull(game); + var copy = new Dictionary(StringComparer.Ordinal); + if (tags is not null) + { + foreach (var pair in tags) + { + if (!PgnTags.IsValidName(pair.Key) || !PgnTags.IsValidValue(pair.Value) + || !copy.TryAdd(pair.Key, pair.Value)) + { + throw new ArgumentException("Tag names and values must be valid and names must be unique.", nameof(tags)); + } + } + } + + if (copy.TryGetValue("Variant", out var variant) && variant != "Standard") + { + throw new ArgumentException("Only standard chess PGN is supported.", nameof(tags)); + } + + if (!PgnTags.TryReadSetup(copy, out var declaredFen)) + { + throw new ArgumentException("SetUp and FEN must describe a consistent starting position.", nameof(tags)); + } + + if (declaredFen is not null && (!ChessGame.TryCreateFromFen(declaredFen, out var declaredGame) + || declaredGame.ToFen() != game.InitialFen)) + { + throw new ArgumentException("The FEN tag must match the game's initial position.", nameof(tags)); + } + + if (copy.TryGetValue("SetUp", out var setup) && setup == "0" && game.InitialFen != ChessGame.StartingFen) + { + throw new ArgumentException("SetUp 0 conflicts with the game's initial position.", nameof(tags)); + } + + if (!PgnTags.IsValidValue(game.InitialFen)) + { + throw new ArgumentException("The initial FEN exceeds the PGN tag-value limit.", nameof(game)); + } + + PgnResult selected = result ?? PgnTags.FromOutcome(game.Outcome.Result); + if (copy.TryGetValue("Result", out var marker)) + { + if (!PgnTags.TryReadResult(marker, out var taggedResult) || (result.HasValue && result.Value != taggedResult)) + { + throw new ArgumentException("The Result tag is invalid or conflicts with the supplied result.", nameof(tags)); + } + + selected = taggedResult; + } + + if (!Enum.IsDefined(selected) || (game.IsFinished && selected != PgnTags.FromOutcome(game.Outcome.Result))) + { + throw new ArgumentException("The declared result is invalid or contradicts the finished game.", nameof(result)); + } + + return new PgnGame(game, copy, selected); + } + + /// Replays the line into an independent game. Does not infer resignation, timeout or draw claims + /// from the declared result; an ongoing engine outcome may accompany a decisive PGN result. + public ChessGame ToGame() => ChessGame.FromRecord(new GameRecord(InitialFen, Moves, null)); + + /// Exports ordered tags and canonical SAN with LF line endings. + public string ToPgn() + { + using var writer = new StringWriter(CultureInfo.InvariantCulture); + WriteTo(writer); + return writer.ToString(); + } + + /// Writes PGN without closing the supplied writer. Writer failures propagate. + public void WriteTo(TextWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + PgnWriter.Write(this, writer); + } + + /// Imports exactly one mainline PGN game with canonical SAN. + /// The PGN is invalid or uses unsupported annotations or variants. + public static PgnGame Parse(ReadOnlySpan text) + => TryParse(text, out var game, out var diagnostic) ? game : throw new PgnParseException(diagnostic); + + /// 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); +} diff --git a/src/ChessRealms.Engine/PgnResult.cs b/src/ChessRealms.Engine/PgnResult.cs new file mode 100644 index 0000000..d079f2a --- /dev/null +++ b/src/ChessRealms.Engine/PgnResult.cs @@ -0,0 +1,11 @@ +namespace ChessRealms.Engine; + +/// The declared PGN result, independent of an engine's board-derived outcome. +public enum PgnResult +{ + /// The marker *, covering ongoing games and unavailable results. + Unknown, + WhiteWin, + BlackWin, + Draw +}