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/README.md b/README.md
index fca26ad..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
@@ -93,13 +92,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..b975a1e 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
@@ -61,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
@@ -107,15 +144,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 +200,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
@@ -212,6 +257,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/performance.md b/docs/performance.md
index bd4747f..d7beac1 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,9 +13,12 @@ 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. |
+| `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
@@ -24,6 +27,22 @@ 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
+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 +60,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 +80,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/docs/pgn.md b/docs/pgn.md
index 09b85ee..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,17 @@ 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
+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,
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/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1
index fe13d43..08be614 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();
@@ -139,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)
{
@@ -153,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 05f2f5f..c59da68 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 };
@@ -61,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/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
new file mode 100644
index 0000000..aca1085
--- /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 (FenFormatter.FormatWithoutValidation(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() => 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() => FenFormatter.FormatWithoutCounters(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/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.Benchmark/PgnBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs
index dc3dc49..4f87051 100644
--- a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs
+++ b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs
@@ -31,9 +31,16 @@ 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.");
+ }
}
- 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.
@@ -76,4 +83,11 @@ private 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.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.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
new file mode 100644
index 0000000..9f86a1c
--- /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 { FenFormatter.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(FenFormatter.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(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(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));
+ 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 { FenFormatter.Format(game.Position) };
+ foreach (var move in ParseLine("e8e7 a1a2"))
+ {
+ Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None));
+ expected.Add(FenFormatter.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..31a48f5
--- /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(FenFormatter.FormatWithoutCounters(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 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(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(FenFormatter.TryFormatWithoutValidation(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(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 })
+ {
+ 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.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/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/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/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.Tests/RepetitionKeyTests.cs b/src/ChessRealms.Engine.Tests/RepetitionKeyTests.cs
new file mode 100644
index 0000000..cb1096d
--- /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(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]);
+ 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(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]);
+ 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.History.cs b/src/ChessRealms.Engine/ChessGame.History.cs
new file mode 100644
index 0000000..905a040
--- /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
+ && FenFormatter.TryFormatWithoutValidation(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 = FenFormatter.FormatWithoutValidation(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.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 fd2aa78..187e8b5 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;
@@ -12,14 +13,14 @@ 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;
- private readonly List history = [];
+ 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();
@@ -47,8 +48,13 @@ public GameState State
}
}
- public int RepetitionCount => repetitions[keys[^1]];
- public IReadOnlyList History => Array.AsReadOnly(history.ToArray());
+ public int RepetitionCount => repetitions[currentRepetitionKey];
+
+ /// 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);
@@ -65,12 +71,11 @@ internal ChessGame(Position position)
}
this.position = position;
- initialFen = FenStrings.Format(position);
+ initialFen = FenFormatter.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);
}
@@ -82,15 +87,21 @@ 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);
- 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)
+ => 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);
@@ -116,6 +127,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)
{
@@ -166,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);
@@ -196,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);
@@ -222,10 +241,10 @@ 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);
+ currentRepetitionKey = key;
repetitions[key] = repetitionCount;
position = nextPosition;
Outcome = outcome;
@@ -241,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;
@@ -272,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);
}
@@ -313,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) =>
@@ -438,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/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/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs
index 04dafa9..d1662ac 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.
@@ -66,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/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/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/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenParser.cs
similarity index 55%
rename from src/ChessRealms.Engine/Parsing/FenStrings.cs
rename to src/ChessRealms.Engine/Parsing/FenParser.cs
index 356151d..6e50e44 100644
--- a/src/ChessRealms.Engine/Parsing/FenStrings.cs
+++ b/src/ChessRealms.Engine/Parsing/FenParser.cs
@@ -1,19 +1,16 @@
using ChessRealms.Engine.Core.Constants;
-using ChessRealms.Engine.Core.Math;
using ChessRealms.Engine.Core.Types;
using System.Globalization;
using System.Numerics;
-using System.Text;
namespace ChessRealms.Engine.Parsing;
-internal static class FenStrings
+internal static class FenParser
{
- internal const string StartPosition = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
-
+ // Parses all six fields, then checks the standard-chess position invariants.
internal static bool TryParse(string? fen, out Position position)
{
- if (TryParseSyntax(fen, out var candidate) && PositionValidation.IsValid(candidate))
+ if (TryParseSyntaxOnly(fen, out var candidate) && PositionValidation.IsValid(candidate))
{
position = candidate;
return true;
@@ -25,7 +22,7 @@ internal static bool TryParse(string? fen, out Position position)
// 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)
+ internal static bool TryParseSyntaxOnly(string? fen, out Position position)
{
position = new();
if (fen is null)
@@ -61,8 +58,8 @@ internal static bool TryParseSyntax(string? fen, out Position position)
return false;
}
- if (!TryParseCounter(fields[4], out candidate.halfMoveClock)
- || !TryParseCounter(fields[5], out candidate.fullMoveCount)
+ if (!TryParseNonNegativeCounter(fields[4], out candidate.halfMoveClock)
+ || !TryParseNonNegativeCounter(fields[5], out candidate.fullMoveCount)
|| candidate.fullMoveCount == 0)
{
return false;
@@ -167,99 +164,10 @@ private static bool TryParseEnPassantSquare(string text, out int square)
return true;
}
- private static bool TryParseCounter(string text, out BigInteger value)
+ 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);
}
-
- 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)
- {
- 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();
- }
-
- // 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();
- }
-
- private static void AppendPositionFields(StringBuilder result, Position position)
- {
- 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)
- {
- result.Append(emptySquares);
- emptySquares = 0;
- }
-
- char symbol = "pnbrqk"[piece.Value];
- result.Append(piece.Color == Colors.White ? char.ToUpperInvariant(symbol) : symbol);
- }
-
- if (emptySquares != 0)
- {
- result.Append(emptySquares);
- }
-
- if (rank != 0)
- {
- result.Append('/');
- }
- }
-
- result.Append(position.color == Colors.White ? " w " : " b ");
- if (position.castlings == Castlings.None)
- {
- result.Append('-');
- }
- else
- {
- for (int i = 0; i < 4; i++)
- {
- if ((position.castlings & (1 << i)) != 0)
- {
- result.Append("KQkq"[i]);
- }
- }
- }
-
- string enPassant = position.enpassant == Squares.Empty
- ? "-"
- : SquareOps.ToAbbreviature(position.enpassant);
- result.Append(' ').Append(enPassant);
- }
}
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/Parsing/PgnWriter.cs b/src/ChessRealms.Engine/Parsing/PgnWriter.cs
index 77331ab..fd90503 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 (!FenParser.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;
+ }
+}
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);
}