From 1aec811b97aa763ea6700cbcaf77375608c2957a Mon Sep 17 00:00:00 2001 From: Hash Date: Sun, 18 Oct 2026 16:13:46 +0300 Subject: [PATCH 1/8] feat(state): add immutable game record export --- docs/game-rules-api.md | 27 ++ .../CompleteGameRulesTests.cs | 2 +- .../GameRecordTests.cs | 233 ++++++++++++++++++ .../PublicApiSurfaceTests.cs | 2 + src/ChessRealms.Engine/ChessGame.cs | 26 +- src/ChessRealms.Engine/DrawClaimRecord.cs | 6 + src/ChessRealms.Engine/GameRecord.cs | 45 ++++ 7 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/ChessRealms.Engine.Tests/GameRecordTests.cs create mode 100644 src/ChessRealms.Engine/DrawClaimRecord.cs create mode 100644 src/ChessRealms.Engine/GameRecord.cs diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 84d66ca..c055091 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -38,6 +38,33 @@ 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. +## Game record export + +`ExportRecord()` creates an independent, immutable `GameRecord` containing the +game's initial FEN, its current line of successful coordinate moves, and a +successful draw claim when one ended the game. Undone moves are excluded; +continuing, undoing or cloning the game does not change an existing record. +The move collection owns its data and cannot be modified through collection +interfaces. A game created from FEN records only the history played since that +position; it cannot recover moves preceding the import. + +```csharp +var game = new ChessGame(); +game.MakeMove(CoordinateMove.Parse("e2e4")); +GameRecord record = game.ExportRecord(); +``` + +`record.DrawClaim` is null for ongoing games and automatic endings. A successful +threefold-repetition or fifty-move claim stores its reason and optional intended +move. That intended move is not part of `record.Moves`. A claim on the current +position has a null intended move. `ChessGame.DrawIntendedMove` exposes the +intended move of a successful claim, is copied by `Clone()`, and is cleared by a +successful `UndoMove()`. A failed undo leaves the claim unchanged. + +This API currently exports an in-memory record only; restoration and JSON +serialization are not implemented. `GameRecord.SchemaVersion` identifies the +record schema and is independent of the package version. + ## FEN validation FEN import requires exactly six fields separated by single ASCII spaces, with diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index f9d7842..0e09e1d 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -21,7 +21,7 @@ private static void Play(ChessGame game, string moves) } private static string State(ChessGame game) => string.Join("|", game.ToFen(), game.Outcome, game.State, - game.RepetitionCount, game.AvailableDrawClaims, string.Join(";", game.History), + game.RepetitionCount, game.AvailableDrawClaims, game.DrawIntendedMove, string.Join(";", game.History), string.Join(",", game.GetLegalMoves())); private static ChessPiece At(ChessGame game, string square) diff --git a/src/ChessRealms.Engine.Tests/GameRecordTests.cs b/src/ChessRealms.Engine.Tests/GameRecordTests.cs new file mode 100644 index 0000000..09de760 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/GameRecordTests.cs @@ -0,0 +1,233 @@ +namespace ChessRealms.Engine.Tests; + +internal class GameRecordTests +{ + private const string RepetitionCycle = "g1f3 g8f6 f3g1 f6g8"; + + [Test] + public void ExportRecord_NewGameHasStartingFenAndNoMovesOrClaim() + { + var game = new ChessGame(); + var record = game.ExportRecord(); + + Assert.Multiple(() => + { + Assert.That(record.InitialFen, Is.EqualTo(ChessGame.StartingFen)); + Assert.That(record.Moves, Is.Empty); + Assert.That(record.DrawClaim, Is.Null); + }); + + Play(game, "e2e4"); + Assert.That(record.Moves, Is.Empty); + } + + [Test] + public void ExportRecord_CustomStartAndCurrentLineSurviveCloningAndBranching() + { + const string fen = "4k3/8/8/8/8/8/8/R3K3 w - - 0 1"; + var game = ChessGame.FromFen(fen); + Play(game, "a1a2 e8e7"); + var snapshot = game.ExportRecord(); + var clone = game.Clone(); + + Assert.That(game.UndoMove(), Is.True); + Play(game, "e8f7"); + var branch = game.ExportRecord(); + + Assert.Multiple(() => + { + Assert.That(snapshot.InitialFen, Is.EqualTo(fen)); + Assert.That(snapshot.Moves, Is.EqualTo(Moves("a1a2 e8e7"))); + Assert.That(branch.InitialFen, Is.EqualTo(fen)); + Assert.That(branch.Moves, Is.EqualTo(Moves("a1a2 e8f7"))); + Assert.That(clone.ExportRecord().InitialFen, Is.EqualTo(fen)); + Assert.That(clone.ExportRecord().Moves, Is.EqualTo(snapshot.Moves)); + }); + + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.ExportRecord().InitialFen, Is.EqualTo(fen)); + Assert.That(game.ExportRecord().Moves, Is.Empty); + Assert.That(branch.Moves, Is.EqualTo(Moves("a1a2 e8f7"))); + } + + [Test] + public void Record_OwnsReadOnlyCopyOfCallerMoves() + { + var source = Moves("e2e4 e7e5").ToList(); + var record = new GameRecord(ChessGame.StartingFen, source, null); + source[0] = CoordinateMove.Parse("d2d4"); + source.Clear(); + + Assert.That(record.Moves, Is.EqualTo(Moves("e2e4 e7e5"))); + var exposed = (IList)record.Moves; + Assert.That(exposed.IsReadOnly, Is.True); + Assert.Throws(() => exposed.Clear()); + Assert.Throws(() => exposed[0] = CoordinateMove.Parse("d2d4")); + Assert.That(record.Moves, Is.EqualTo(Moves("e2e4 e7e5"))); + } + + [TestCase("q")] + [TestCase("r")] + [TestCase("b")] + [TestCase("n")] + public void ExportRecord_PreservesSelectedPromotion(string promotion) + { + const string fen = "7k/P7/8/8/8/8/8/7K w - - 0 1"; + var game = ChessGame.FromFen(fen); + var move = CoordinateMove.Parse("a7a8" + promotion); + Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); + + var record = game.ExportRecord(); + Assert.That(record.InitialFen, Is.EqualTo(fen)); + Assert.That(record.Moves, Is.EqualTo(new[] { move })); + Assert.That(record.DrawClaim, Is.Null); + } + + [TestCase(ChessGame.StartingFen, RepetitionCycle + " " + RepetitionCycle, + DrawClaim.ThreefoldRepetition, null)] + [TestCase(ChessGame.StartingFen, RepetitionCycle + " g1f3 g8f6 f3g1", + DrawClaim.ThreefoldRepetition, "f6g8")] + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 99 50", "a1a2", + DrawClaim.FiftyMoveRule, null)] + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 99 50", "", + DrawClaim.FiftyMoveRule, "a1a2")] + public void ExportRecord_RecordsOnlySuccessfulClaimWithoutPlayingIntendedMove( + string fen, string playedMoves, DrawClaim reason, string? intendedMoveText) + { + var game = ChessGame.FromFen(fen); + Play(game, playedMoves); + CoordinateMove? intendedMove = intendedMoveText is null + ? null : CoordinateMove.Parse(intendedMoveText); + string before = game.ToFen(); + var unclaimed = game.ExportRecord(); + + Assert.That(game.ClaimDraw(reason, intendedMove), Is.True); + var claimed = game.ExportRecord(); + + Assert.Multiple(() => + { + Assert.That(unclaimed.DrawClaim, Is.Null); + Assert.That(claimed.DrawClaim, Is.EqualTo(new DrawClaimRecord(reason, intendedMove))); + Assert.That(claimed.InitialFen, Is.EqualTo(fen)); + Assert.That(claimed.Moves, Is.EqualTo(Moves(playedMoves))); + Assert.That(game.ToFen(), Is.EqualTo(before)); + Assert.That(game.DrawIntendedMove, Is.EqualTo(intendedMove)); + }); + + // Rejected later claims must not replace the successful claim's details. + Assert.That(game.ClaimDraw(reason), Is.False); + Assert.That(game.ExportRecord().DrawClaim, Is.EqualTo(claimed.DrawClaim)); + Assert.That(game.DrawIntendedMove, Is.EqualTo(intendedMove)); + } + + [Test] + public void IntendedClaim_ClonePreservesDetailsAndSuccessfulUndoClearsOnlyClone() + { + const string fen = "4k3/8/8/8/8/8/8/R3K3 w - - 98 50"; + var game = ChessGame.FromFen(fen); + Play(game, "a1a2"); + var intendedMove = CoordinateMove.Parse("e8e7"); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, intendedMove), Is.True); + var snapshot = game.ExportRecord(); + var clone = game.Clone(); + + Assert.Multiple(() => + { + Assert.That(clone.DrawIntendedMove, Is.EqualTo(intendedMove)); + Assert.That(clone.ExportRecord().InitialFen, Is.EqualTo(fen)); + Assert.That(clone.ExportRecord().Moves, Is.EqualTo(snapshot.Moves)); + Assert.That(clone.ExportRecord().DrawClaim, Is.EqualTo(snapshot.DrawClaim)); + Assert.That(clone.Outcome, Is.EqualTo(game.Outcome)); + }); + + Assert.That(clone.UndoMove(), Is.True); + Assert.Multiple(() => + { + Assert.That(clone.DrawIntendedMove, Is.Null); + Assert.That(clone.IsFinished, Is.False); + Assert.That(clone.ToFen(), Is.EqualTo(fen)); + Assert.That(clone.ExportRecord().DrawClaim, Is.Null); + Assert.That(clone.ExportRecord().Moves, Is.Empty); + Assert.That(game.DrawIntendedMove, Is.EqualTo(intendedMove)); + Assert.That(game.ExportRecord().DrawClaim, Is.EqualTo(snapshot.DrawClaim)); + }); + + Play(clone, "a1a3"); + Assert.That(clone.ExportRecord().DrawClaim, Is.Null); + Assert.That(snapshot.DrawClaim, Is.EqualTo(new DrawClaimRecord(DrawClaim.FiftyMoveRule, intendedMove))); + Assert.That(snapshot.Moves, Is.EqualTo(Moves("a1a2"))); + } + + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 99 50", "a1a2")] + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 100 50", null)] + public void ClaimWithoutHistory_FailedUndoPreservesClaim(string fen, string? intendedMoveText) + { + var game = ChessGame.FromFen(fen); + CoordinateMove? intendedMove = intendedMoveText is null + ? null : CoordinateMove.Parse(intendedMoveText); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, intendedMove), Is.True); + + foreach (var copy in new[] { game, game.Clone() }) + { + Assert.That(copy.UndoMove(), Is.False); + Assert.That(copy.DrawIntendedMove, Is.EqualTo(intendedMove)); + Assert.That(copy.ExportRecord().DrawClaim, + Is.EqualTo(new DrawClaimRecord(DrawClaim.FiftyMoveRule, intendedMove))); + Assert.That(copy.ExportRecord().Moves, Is.Empty); + Assert.That(copy.ToFen(), Is.EqualTo(fen)); + Assert.That(copy.Outcome.Reason, Is.EqualTo(FinishReason.FiftyMoveRule)); + } + } + + [Test] + public void RejectedClaims_DoNotBecomeRecordedClaims() + { + var game = ChessGame.FromFen("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule), Is.False); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1b2")), Is.False); + Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition, CoordinateMove.Parse("a1a2")), Is.False); + Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition | DrawClaim.FiftyMoveRule, + CoordinateMove.Parse("a1a2")), Is.False); + + Assert.That(game.DrawIntendedMove, Is.Null); + Assert.That(game.ExportRecord().DrawClaim, Is.Null); + Assert.That(game.ExportRecord().Moves, Is.Empty); + Assert.That(game.IsFinished, Is.False); + } + + [TestCase("7k/6Q1/6K1/8/8/8/8/8 b - - 0 1", FinishReason.Checkmate)] + [TestCase("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", FinishReason.Stalemate)] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1", FinishReason.DeadPosition)] + [TestCase("4k3/8/8/8/8/8/8/R3K3 w - - 150 80", FinishReason.SeventyFiveMoveRule)] + public void AutomaticEndings_AreNotExportedAsClaims(string fen, FinishReason reason) + { + var game = ChessGame.FromFen(fen); + Assert.That(game.Outcome.Reason, Is.EqualTo(reason)); + Assert.That(game.ExportRecord().DrawClaim, Is.Null); + Assert.That(game.ExportRecord().InitialFen, Is.EqualTo(fen)); + } + + [Test] + public void FivefoldRepetition_PreservesAllMovesWithoutInventingClaim() + { + var game = new ChessGame(); + string playedMoves = string.Join(" ", Enumerable.Repeat(RepetitionCycle, 4)); + Play(game, playedMoves); + + Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + var record = game.ExportRecord(); + Assert.That(record.InitialFen, Is.EqualTo(ChessGame.StartingFen)); + Assert.That(record.Moves, Is.EqualTo(Moves(playedMoves))); + Assert.That(record.DrawClaim, Is.Null); + } + + private static CoordinateMove[] Moves(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(move => CoordinateMove.Parse(move)).ToArray(); + + private static void Play(ChessGame game, string text) + { + foreach (var move in Moves(text)) + Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None), move.ToString()); + } +} diff --git a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs index b38c6f6..94b338d 100644 --- a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs +++ b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs @@ -48,8 +48,10 @@ public void ExportedTypes_AreTheReviewedRootSurface() "ChessRealms.Engine.ChessPiece", "ChessRealms.Engine.CoordinateMove", "ChessRealms.Engine.DrawClaim", + "ChessRealms.Engine.DrawClaimRecord", "ChessRealms.Engine.FinishReason", "ChessRealms.Engine.GameOutcome", + "ChessRealms.Engine.GameRecord", "ChessRealms.Engine.GameResult", "ChessRealms.Engine.GameState", "ChessRealms.Engine.MoveHistoryEntry", diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 6e3cbc1..399e035 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -14,6 +14,7 @@ public sealed class ChessGame public const string StartingFen = FenStrings.StartPosition; private Position position; + private readonly string initialFen; private readonly List history = []; private readonly List undo = []; private readonly List keys = []; @@ -22,9 +23,12 @@ public sealed class ChessGame public PieceColor CurrentColor => position.color.ToPublicColor(); public PieceColor OpponentColor => Colors.Mirror(position.color).ToPublicColor(); internal Position Position => position; + internal string InitialFen => initialFen; public BigInteger HalfmoveClock => position.halfMoveClock; public BigInteger FullmoveNumber => position.fullMoveCount; public GameOutcome Outcome { get; private set; } = GameOutcome.Ongoing; + /// The unplayed move of a successful intended-move draw claim, or null otherwise. + public CoordinateMove? DrawIntendedMove { get; private set; } public bool IsFinished => Outcome.Result != GameResult.Ongoing; public bool IsInCheck => position.IsKingChecked(); public GameState State => IsFinished ? GameState.Finished : IsInCheck ? GameState.Check : GameState.Active; @@ -38,6 +42,7 @@ internal ChessGame(Position position) { if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); this.position = position; + initialFen = FenStrings.Format(position); var moves = LegalMoves(position); string key = RepetitionKey(position, moves); keys.Add(key); @@ -48,7 +53,9 @@ internal ChessGame(Position position) private ChessGame(ChessGame original) { position = original.position; + initialFen = original.initialFen; Outcome = original.Outcome; + DrawIntendedMove = original.DrawIntendedMove; history.AddRange(original.history); undo.AddRange(original.undo); keys.AddRange(original.keys); @@ -58,6 +65,9 @@ private ChessGame(ChessGame original) public ChessGame Clone() => new(this); public string ToFen() => FenStrings.FormatUnchecked(position); + /// Exports an independent snapshot of the starting position, current move line and draw claim. + public GameRecord ExportRecord() => GameRecord.CreateFromGame(this); + /// Copies the board in a1-to-h8 order. public void CopyBoardTo(Span destination) { @@ -127,6 +137,7 @@ public bool UndoMove() undo.RemoveAt(undo.Count - 1); history.RemoveAt(history.Count - 1); Outcome = Evaluate(position, LegalMoves(position).Count, RepetitionCount); + DrawIntendedMove = null; return true; } @@ -144,10 +155,21 @@ public DrawClaim GetAvailableDrawClaims(CoordinateMove intendedMove) /// A valid intended-move claim ends the game at its current board; the move is not played. public bool ClaimDraw(DrawClaim reason, CoordinateMove? intendedMove = null) { - if (reason is not (DrawClaim.ThreefoldRepetition or DrawClaim.FiftyMoveRule)) return false; + if (reason is not (DrawClaim.ThreefoldRepetition or DrawClaim.FiftyMoveRule)) + { + return false; + } + DrawClaim available = intendedMove is { } move ? GetAvailableDrawClaims(move) : AvailableDrawClaims; - if ((available & reason) == 0) return false; + + if ((available & reason) == 0) + { + return false; + } + Outcome = Draw(reason == DrawClaim.ThreefoldRepetition ? FinishReason.ThreefoldRepetition : FinishReason.FiftyMoveRule); + DrawIntendedMove = intendedMove; + return true; } diff --git a/src/ChessRealms.Engine/DrawClaimRecord.cs b/src/ChessRealms.Engine/DrawClaimRecord.cs new file mode 100644 index 0000000..c197c64 --- /dev/null +++ b/src/ChessRealms.Engine/DrawClaimRecord.cs @@ -0,0 +1,6 @@ +namespace ChessRealms.Engine; + +/// A successful draw claim, optionally based on a legal move that was not played. +/// The claimed threefold-repetition or fifty-move rule. +/// The unplayed intended move, or null for a claim on the current position. +public sealed record DrawClaimRecord(DrawClaim Reason, CoordinateMove? IntendedMove = null); diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs new file mode 100644 index 0000000..eaade2d --- /dev/null +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -0,0 +1,45 @@ +namespace ChessRealms.Engine; + +/// An immutable snapshot of a game's starting position, current move line and draw claim. +public sealed class GameRecord +{ + /// The version of the game record schema. + public const int SchemaVersion = 1; + + /// The position from which the recorded moves were played. + public string InitialFen { get; } + + /// A read-only snapshot of the successfully played moves, excluding undone moves. + public IReadOnlyList Moves { get; } + + /// The successful draw claim, or null if no claim ended the game. + public DrawClaimRecord? DrawClaim { get; } + + internal GameRecord(string fen, IReadOnlyList moves, DrawClaimRecord? drawClaim) + { + ArgumentNullException.ThrowIfNull(fen); + ArgumentNullException.ThrowIfNull(moves); + + InitialFen = fen; + Moves = Array.AsReadOnly(moves.ToArray()); + DrawClaim = drawClaim; + } + + internal static GameRecord CreateFromGame(ChessGame chessGame) + { + ArgumentNullException.ThrowIfNull(chessGame); + + var fen = chessGame.InitialFen; + var moves = chessGame.History.Select(x => x.Move).ToArray(); + DrawClaimRecord? drawClaim = chessGame.Outcome.Reason switch + { + FinishReason.ThreefoldRepetition => new(ChessRealms.Engine.DrawClaim.ThreefoldRepetition, + chessGame.DrawIntendedMove), + FinishReason.FiftyMoveRule => new(ChessRealms.Engine.DrawClaim.FiftyMoveRule, + chessGame.DrawIntendedMove), + _ => null + }; + + return new GameRecord(fen, moves, drawClaim); + } +} From 563c6e5fc02aac25d504e7d2d01ee4573a8ceb13 Mon Sep 17 00:00:00 2001 From: Hash Date: Sun, 18 Oct 2026 16:23:09 +0300 Subject: [PATCH 2/8] chore: move sdk to next version --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 532d85c..1907c09 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.401", + "version": "10.0.402", "rollForward": "disable", "allowPrerelease": false }, From adc9ca90049cf6607ca6d6e504cfb758afed0955 Mon Sep 17 00:00:00 2001 From: Hash Date: Sun, 18 Oct 2026 20:15:58 +0300 Subject: [PATCH 3/8] feat(state): restore games from validated records --- docs/game-rules-api.md | 22 +- .../GameRecordRestoreTests.cs | 216 ++++++++++++++++++ src/ChessRealms.Engine/ChessGame.cs | 5 + src/ChessRealms.Engine/GameRecord.cs | 33 +++ 4 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index c055091..6b3f60e 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -38,7 +38,7 @@ 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. -## Game record export +## Game record export and restoration `ExportRecord()` creates an independent, immutable `GameRecord` containing the game's initial FEN, its current line of successful coordinate moves, and a @@ -52,6 +52,7 @@ position; it cannot recover moves preceding the import. var game = new ChessGame(); game.MakeMove(CoordinateMove.Parse("e2e4")); GameRecord record = game.ExportRecord(); +ChessGame restored = ChessGame.FromRecord(record); ``` `record.DrawClaim` is null for ongoing games and automatic endings. A successful @@ -61,9 +62,22 @@ position has a null intended move. `ChessGame.DrawIntendedMove` exposes the intended move of a successful claim, is copied by `Clone()`, and is cleared by a successful `UndoMove()`. A failed undo leaves the claim unchanged. -This API currently exports an in-memory record only; restoration and JSON -serialization are not implemented. `GameRecord.SchemaVersion` identifies the -record schema and is independent of the package version. +`ChessGame.FromRecord(record)` creates an independent game from the initial FEN, +replays each move through `MakeMove`, then applies the optional claim through +`ClaimDraw`. This rebuilds history, repetition counts, available draw claims and +the outcome, including automatic endings. The intended move of a claim remains +unplayed. The restored game supports continued play and `UndoMove` with the same +rules as the original game. + +A null record throws `ArgumentNullException`. An invalid initial FEN, an +unplayable move (including a move after the game has finished), or an invalid or +unavailable draw claim throws `FormatException`. Move errors identify the +zero-based index in `record.Moves`. Restoration returns a game only after the +whole record succeeds; it does not change the record or the original game. + +Records currently remain in memory; JSON serialization is not implemented. +`GameRecord.SchemaVersion` identifies the record schema and is independent of +the package version. ## FEN validation diff --git a/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs b/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs new file mode 100644 index 0000000..ee0a258 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs @@ -0,0 +1,216 @@ +namespace ChessRealms.Engine.Tests; + +internal class GameRecordRestoreTests +{ + private const string RepetitionCycle = "g1f3 g8f6 f3g1 f6g8"; + private const string RookEnding = "4k3/8/8/8/8/8/8/R3K3 w - - "; + + [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", "a7a8r")] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8b")] + [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, RepetitionCycle + " " + RepetitionCycle)] + [TestCase(ChessGame.StartingFen, + RepetitionCycle + " " + RepetitionCycle + " " + RepetitionCycle + " " + RepetitionCycle)] + [TestCase(RookEnding + "100 50", "")] + [TestCase(RookEnding + "149 80", "a1a2")] + [TestCase("7k/6Q1/6K1/8/8/8/8/8 b - - 0 1", "")] + [TestCase("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", "")] + [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1", "")] + [TestCase(RookEnding + "150 80", "")] + public void FromRecord_RebuildsStateHistoryAndUndo(string fen, string playedMoves) + { + var original = ChessGame.FromFen(fen); + Play(original, playedMoves); + var record = original.ExportRecord(); + + var restored = ChessGame.FromRecord(record); + + Assert.That(restored, Is.Not.SameAs(original)); + AssertEquivalent(original, restored); + AssertUndoEquivalent(original, restored); + Assert.That(record.Moves, Is.EqualTo(Moves(playedMoves))); + } + + [TestCase(ChessGame.StartingFen, RepetitionCycle + " " + RepetitionCycle, + DrawClaim.ThreefoldRepetition, null)] + [TestCase(ChessGame.StartingFen, RepetitionCycle + " g1f3 g8f6 f3g1", + DrawClaim.ThreefoldRepetition, "f6g8")] + [TestCase(RookEnding + "99 50", "a1a2", DrawClaim.FiftyMoveRule, null)] + [TestCase(RookEnding + "98 50", "a1a2", DrawClaim.FiftyMoveRule, "e8e7")] + [TestCase(RookEnding + "100 50", "", DrawClaim.FiftyMoveRule, null)] + [TestCase(RookEnding + "99 50", "", DrawClaim.FiftyMoveRule, "a1a2")] + public void FromRecord_RestoresClaimWithoutPlayingIntendedMove( + string fen, string playedMoves, DrawClaim reason, string? intendedMoveText) + { + var original = ChessGame.FromFen(fen); + Play(original, playedMoves); + CoordinateMove? intendedMove = intendedMoveText is null + ? null : CoordinateMove.Parse(intendedMoveText); + string beforeClaim = original.ToFen(); + Assert.That(original.ClaimDraw(reason, intendedMove), Is.True); + var record = original.ExportRecord(); + + var restored = ChessGame.FromRecord(record); + + AssertEquivalent(original, restored); + Assert.That(restored.ToFen(), Is.EqualTo(beforeClaim)); + Assert.That(restored.History.Select(entry => entry.Move), Is.EqualTo(Moves(playedMoves))); + Assert.That(restored.DrawIntendedMove, Is.EqualTo(intendedMove)); + AssertUndoEquivalent(original, restored); + Assert.That(record.DrawClaim, Is.EqualTo(new DrawClaimRecord(reason, intendedMove))); + } + + [Test] + public void FromRecord_ContinuesRepetitionHistoryWithIndependentOwnership() + { + var original = new ChessGame(); + Play(original, RepetitionCycle); + var record = original.ExportRecord(); + var restored = ChessGame.FromRecord(record); + var secondRestore = ChessGame.FromRecord(record); + + Play(restored, RepetitionCycle); + Assert.That(restored.RepetitionCount, Is.EqualTo(3)); + Assert.That(restored.AvailableDrawClaims, Is.EqualTo(DrawClaim.ThreefoldRepetition)); + Assert.That(original.RepetitionCount, Is.EqualTo(2)); + 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.RepetitionCount, Is.EqualTo(5)); + Assert.That(restored.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition)); + Assert.That(record.Moves, Is.EqualTo(Moves(RepetitionCycle))); + AssertEquivalent(original, secondRestore); + } + + [Test] + public void FromRecord_NullRecordIsRejected() + { + var error = Assert.Throws(() => ChessGame.FromRecord(null!)); + Assert.That(error!.ParamName, Is.EqualTo("gameRecord")); + } + + [TestCase("")] + [TestCase("not a FEN")] + [TestCase("8/8/8/8/8/8/8/8 w - - 0 1")] + public void FromRecord_InvalidInitialFenIsRejected(string fen) + { + var record = new GameRecord(fen, [], null); + + var error = Assert.Throws(() => ChessGame.FromRecord(record)); + + Assert.That(error!.Message, Does.Contain("initial FEN")); + } + + [TestCase(ChessGame.StartingFen, "e2e5", 0)] + [TestCase(ChessGame.StartingFen, "e2e4 d2d4", 1)] + [TestCase(ChessGame.StartingFen, "f2f3 e7e5 g2g4 d8h4 a2a3", 4)] + [TestCase(RookEnding + "149 80", "a1a2 e8e7", 1)] + [TestCase(RookEnding + "150 80", "a1a2", 0)] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8", 0)] + public void FromRecord_UnplayableMoveIsRejectedWithItsIndex(string fen, string moves, int invalidIndex) + { + var record = new GameRecord(fen, Moves(moves), null); + + var error = Assert.Throws(() => ChessGame.FromRecord(record)); + + Assert.That(error!.Message, Does.Contain($"index {invalidIndex}")); + Assert.That(record.Moves, Is.EqualTo(Moves(moves))); + } + + [Test] + public void FromRecord_DefaultMoveIsRejectedAfterValidPrefix() + { + var record = new GameRecord(ChessGame.StartingFen, [CoordinateMove.Parse("e2e4"), default], null); + + var error = Assert.Throws(() => ChessGame.FromRecord(record)); + + Assert.That(error!.Message, Does.Contain("index 1")); + } + + [TestCase(RookEnding + "100 50", "", DrawClaim.None, null)] + [TestCase(RookEnding + "100 50", "", DrawClaim.ThreefoldRepetition | DrawClaim.FiftyMoveRule, null)] + [TestCase(RookEnding + "100 50", "", (DrawClaim)128, null)] + [TestCase(RookEnding + "99 50", "", DrawClaim.FiftyMoveRule, null)] + [TestCase(RookEnding + "99 50", "", DrawClaim.FiftyMoveRule, "a1b2")] + [TestCase(RookEnding + "99 50", "", DrawClaim.ThreefoldRepetition, "a1a2")] + [TestCase(ChessGame.StartingFen, RepetitionCycle, DrawClaim.ThreefoldRepetition, null)] + [TestCase(ChessGame.StartingFen, "g1f3 g8f6 f3g1", DrawClaim.ThreefoldRepetition, "f6g8")] + [TestCase(RookEnding + "149 80", "a1a2", DrawClaim.FiftyMoveRule, null)] + public void FromRecord_InvalidOrUnavailableClaimIsRejected( + string fen, string moves, DrawClaim reason, string? intendedMoveText) + { + CoordinateMove? intendedMove = intendedMoveText is null + ? null : CoordinateMove.Parse(intendedMoveText); + var claim = new DrawClaimRecord(reason, intendedMove); + var record = new GameRecord(fen, Moves(moves), claim); + + var error = Assert.Throws(() => ChessGame.FromRecord(record)); + + Assert.That(error!.Message, Does.Contain("draw claim")); + Assert.That(record.DrawClaim, Is.EqualTo(claim)); + Assert.That(record.Moves, Is.EqualTo(Moves(moves))); + } + + [Test] + public void FromRecord_DefaultIntendedMoveIsRejected() + { + var claim = new DrawClaimRecord(DrawClaim.FiftyMoveRule, default(CoordinateMove)); + var record = new GameRecord(RookEnding + "100 50", [], claim); + + Assert.Throws(() => ChessGame.FromRecord(record)); + } + + private static void AssertEquivalent(ChessGame expected, ChessGame actual) + { + var expectedRecord = expected.ExportRecord(); + var actualRecord = actual.ExportRecord(); + Assert.Multiple(() => + { + Assert.That(actual.ToFen(), Is.EqualTo(expected.ToFen())); + Assert.That(actual.Outcome, Is.EqualTo(expected.Outcome)); + Assert.That(actual.State, Is.EqualTo(expected.State)); + Assert.That(actual.History, Is.EqualTo(expected.History)); + Assert.That(actual.RepetitionCount, Is.EqualTo(expected.RepetitionCount)); + Assert.That(actual.AvailableDrawClaims, Is.EqualTo(expected.AvailableDrawClaims)); + Assert.That(actual.DrawIntendedMove, Is.EqualTo(expected.DrawIntendedMove)); + Assert.That(actual.GetLegalMoves(), Is.EquivalentTo(expected.GetLegalMoves())); + Assert.That(actualRecord.InitialFen, Is.EqualTo(expectedRecord.InitialFen)); + Assert.That(actualRecord.Moves, Is.EqualTo(expectedRecord.Moves)); + Assert.That(actualRecord.DrawClaim, Is.EqualTo(expectedRecord.DrawClaim)); + }); + } + + private static void AssertUndoEquivalent(ChessGame original, ChessGame restored) + { + int moveCount = original.History.Count; + for (int i = 0; i < moveCount; i++) + { + Assert.That(original.UndoMove(), Is.True); + Assert.That(restored.UndoMove(), Is.True); + AssertEquivalent(original, restored); + } + + Assert.That(original.UndoMove(), Is.False); + Assert.That(restored.UndoMove(), Is.False); + AssertEquivalent(original, restored); + } + + private static CoordinateMove[] Moves(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(move => CoordinateMove.Parse(move)).ToArray(); + + private static void Play(ChessGame game, string text) + { + foreach (var move in Moves(text)) + Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None), move.ToString()); + } +} diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 399e035..08262f4 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -68,6 +68,11 @@ private ChessGame(ChessGame original) /// Exports an independent snapshot of the starting position, current move line and draw claim. public GameRecord ExportRecord() => GameRecord.CreateFromGame(this); + /// Restores an independent game by replaying the record's moves and applying its draw claim. + /// The record is null. + /// The initial FEN, a recorded move or the draw claim is invalid. + public static ChessGame FromRecord(GameRecord gameRecord) => GameRecord.RestoreGame(gameRecord); + /// Copies the board in a1-to-h8 order. public void CopyBoardTo(Span destination) { diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index eaade2d..2633eca 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -1,3 +1,6 @@ +using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Parsing; + namespace ChessRealms.Engine; /// An immutable snapshot of a game's starting position, current move line and draw claim. @@ -42,4 +45,34 @@ internal static GameRecord CreateFromGame(ChessGame chessGame) return new GameRecord(fen, moves, drawClaim); } + + internal static ChessGame RestoreGame(GameRecord gameRecord) + { + ArgumentNullException.ThrowIfNull(gameRecord); + + if (!FenStrings.TryParse(gameRecord.InitialFen, out Position position)) + { + throw new FormatException("The game record contains an invalid initial FEN."); + } + + var game = new ChessGame(position); + + for (int i = 0; i < gameRecord.Moves.Count; ++i) + { + CoordinateMove move = gameRecord.Moves[i]; + MoveResult result = game.MakeMove(move); + + if (result == MoveResult.None) + { + throw new FormatException($"The game record contains an unplayable move at index {i}: '{move}'."); + } + } + + if (gameRecord.DrawClaim is { } claim && !game.ClaimDraw(claim.Reason, claim.IntendedMove)) + { + throw new FormatException("The game record contains an invalid or unavailable draw claim."); + } + + return game; + } } From f869428604217379612e78a053d5b7c78ac3178b Mon Sep 17 00:00:00 2001 From: Hash Date: Sun, 18 Oct 2026 20:42:51 +0300 Subject: [PATCH 4/8] refactor(engine): improve code readability --- .editorconfig | 7 + CONTRIBUTING.md | 5 + Directory.Build.props | 1 + src/ChessRealms.Engine.Console/Program.cs | 23 +- src/ChessRealms.Engine.Perft/PerftDriver.cs | 6 + .../CompleteGameRulesTests.cs | 11 +- .../GameRecordRestoreTests.cs | 2 + .../GameRecordTests.cs | 2 + .../Perft/PerftTests.cs | 2 + src/ChessRealms.Engine/ChessGame.cs | 300 +++++++++++++----- src/ChessRealms.Engine/ChessPiece.cs | 5 + src/ChessRealms.Engine/CoordinateMove.cs | 35 +- .../Core/Attacks/BishopAttacks.cs | 30 +- .../Core/Attacks/Occupancy.cs | 2 + .../Core/Attacks/RookAttacks.cs | 18 +- .../Core/Movements/MoveDriver.cs | 2 + src/ChessRealms.Engine/Core/Types/Position.cs | 185 +++++++---- .../Core/Types/PositionValidation.cs | 126 ++++++-- src/ChessRealms.Engine/Debugs/DebugHelper.cs | 15 +- src/ChessRealms.Engine/GameOutcome.cs | 23 +- src/ChessRealms.Engine/GameRecord.cs | 2 +- src/ChessRealms.Engine/Parsing/FenStrings.cs | 219 ++++++++++--- src/ChessRealms.Engine/Square.cs | 11 +- 23 files changed, 779 insertions(+), 253 deletions(-) diff --git a/.editorconfig b/.editorconfig index b83dcd1..00b73c4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,10 @@ root = true end_of_line = lf charset = utf-8 insert_final_newline = true + +[*.cs] +csharp_prefer_braces = true +csharp_new_line_before_open_brace = all +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +dotnet_diagnostic.IDE0011.severity = warning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a091798..00b4ff0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,11 @@ Use UTF-8, LF line endings and a final newline, as configured by `.editorconfig` and `.gitattributes`. Preserve public API compatibility unless a change explicitly calls for a breaking release. +Use braces for all control-flow bodies, including single statements, and expand +these blocks onto separate lines. Keep simple auto-properties and short +expression-bodied members compact when they remain easy to read. The build +reports missing braces through `IDE0011`. + Use `/` branch names and `(): ` commit and PR titles. Common types are `feature` (title type `feat`), `fix`, `docs`, `test`, `refactor`, `perf`, `ci` diff --git a/Directory.Build.props b/Directory.Build.props index f860221..4c0c213 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ true + true diff --git a/src/ChessRealms.Engine.Console/Program.cs b/src/ChessRealms.Engine.Console/Program.cs index ffbd89a..ae382da 100644 --- a/src/ChessRealms.Engine.Console/Program.cs +++ b/src/ChessRealms.Engine.Console/Program.cs @@ -14,9 +14,21 @@ Console.WriteLine("Draw claims: {0}", chessGame.AvailableDrawClaims); Console.Write("> "); string? input = Console.ReadLine(); - if (input is null || input == "quit") break; - if (input == "fen") { Console.WriteLine(chessGame.ToFen()); continue; } - if (input == "moves") { Console.WriteLine(string.Join(" ", chessGame.GetLegalMoves())); continue; } + if (input is null || input == "quit") + { + break; + } + + if (input == "fen") + { + Console.WriteLine(chessGame.ToFen()); + continue; + } + if (input == "moves") + { + Console.WriteLine(string.Join(" ", chessGame.GetLegalMoves())); + continue; + } if (input == "undo") { Console.WriteLine(chessGame.UndoMove() ? "Move undone." : "No move to undo."); @@ -35,7 +47,10 @@ } bool success = CoordinateMove.TryParse(input, out var move) && (lastMoveResult = chessGame.MakeMove(move)) != MoveResult.None; - if (!success) Console.WriteLine("Invalid move or game already finished."); + if (!success) + { + Console.WriteLine("Invalid move or game already finished."); + } } static void PrintBoard(ChessGame chessGame) { diff --git a/src/ChessRealms.Engine.Perft/PerftDriver.cs b/src/ChessRealms.Engine.Perft/PerftDriver.cs index f9484c1..51dc414 100644 --- a/src/ChessRealms.Engine.Perft/PerftDriver.cs +++ b/src/ChessRealms.Engine.Perft/PerftDriver.cs @@ -53,7 +53,9 @@ internal static PerftResult Test(Position pos, int depth, bool upper = true) perftResult.Nodes += 1; if (BinaryMoveOps.DecodeCapture(moves[i]) != 0) + { perftResult.Captures += 1; + } if (BinaryMoveOps.DecodeEnpassant(moves[i]) != 0) { @@ -61,10 +63,14 @@ internal static PerftResult Test(Position pos, int depth, bool upper = true) } if (BinaryMoveOps.DecodeCastling(moves[i]) != 0) + { perftResult.Castles += 1; + } if (BinaryMoveOps.DecodePromotion(moves[i]) != 0) + { perftResult.Promotions += 1; + } } return perftResult; diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index 0e09e1d..a77ca34 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -10,14 +10,20 @@ internal class CompleteGameRulesTests { private static ChessGame Game(string fen) { - if (!ChessGame.TryCreateFromFen(fen, out var game)) Assert.Fail("Invalid fixture: " + fen); + if (!ChessGame.TryCreateFromFen(fen, out var game)) + { + Assert.Fail("Invalid fixture: " + fen); + } + return game!; } private static void Play(ChessGame game, string moves) { foreach (string move in moves.Split(' ')) + { Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.Not.EqualTo(MoveResult.None), move); + } } private static string State(ChessGame game) => string.Join("|", game.ToFen(), game.Outcome, game.State, @@ -87,7 +93,10 @@ public void RejectedMovesAndSnapshots_CannotMutateOwnedHistory() string before = State(game); foreach (var move in new[] { default(CoordinateMove), CoordinateMove.Parse("e7e5q"), CoordinateMove.Parse("e2e4") }) + { Assert.That(game.MakeMove(move), Is.EqualTo(MoveResult.None)); + } + Assert.Throws(() => new CoordinateMove(new Square(Squares.e7), new Square(Squares.e5), (PieceValue)99)); var history = (IList)game.History; diff --git a/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs b/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs index ee0a258..a1b1061 100644 --- a/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs +++ b/src/ChessRealms.Engine.Tests/GameRecordRestoreTests.cs @@ -211,6 +211,8 @@ private static CoordinateMove[] Moves(string text) => text.Split(' ', StringSpli private static void Play(ChessGame game, string text) { foreach (var move in Moves(text)) + { Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None), move.ToString()); + } } } diff --git a/src/ChessRealms.Engine.Tests/GameRecordTests.cs b/src/ChessRealms.Engine.Tests/GameRecordTests.cs index 09de760..dfce296 100644 --- a/src/ChessRealms.Engine.Tests/GameRecordTests.cs +++ b/src/ChessRealms.Engine.Tests/GameRecordTests.cs @@ -228,6 +228,8 @@ private static CoordinateMove[] Moves(string text) => text.Split(' ', StringSpli private static void Play(ChessGame game, string text) { foreach (var move in Moves(text)) + { Assert.That(game.MakeMove(move), Is.Not.EqualTo(MoveResult.None), move.ToString()); + } } } diff --git a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs index 787b5e4..688c0e4 100644 --- a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs +++ b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs @@ -29,8 +29,10 @@ private static IEnumerable Cases(bool deep) for (int depth = 1; depth <= nodes.Length; depth++) { if ((depth > 3) == deep) + { yield return new TestCaseData(fen, depth, nodes[depth - 1]) .SetName($"{(deep ? "Deep" : "Fast")}_{name}_Depth{depth}"); + } } } } diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 08262f4..11c95a3 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -3,6 +3,7 @@ using ChessRealms.Engine.Core.Movements; using ChessRealms.Engine.Core.Types; using ChessRealms.Engine.Parsing; +using System.Diagnostics.CodeAnalysis; using System.Numerics; namespace ChessRealms.Engine; @@ -27,27 +28,49 @@ public sealed class ChessGame public BigInteger HalfmoveClock => position.halfMoveClock; public BigInteger FullmoveNumber => position.fullMoveCount; public GameOutcome Outcome { get; private set; } = GameOutcome.Ongoing; + /// The unplayed move of a successful intended-move draw claim, or null otherwise. public CoordinateMove? DrawIntendedMove { get; private set; } + public bool IsFinished => Outcome.Result != GameResult.Ongoing; public bool IsInCheck => position.IsKingChecked(); - public GameState State => IsFinished ? GameState.Finished : IsInCheck ? GameState.Check : GameState.Active; + public GameState State + { + get + { + if (IsFinished) + { + return GameState.Finished; + } + + return IsInCheck ? GameState.Check : GameState.Active; + } + } + public int RepetitionCount => repetitions[keys[^1]]; public IReadOnlyList History => Array.AsReadOnly(history.ToArray()); - public DrawClaim AvailableDrawClaims => IsFinished ? DrawClaim.None : Claims(position, RepetitionCount); + public DrawClaim AvailableDrawClaims => IsFinished + ? DrawClaim.None + : CalculateDrawClaims(position, RepetitionCount); - public ChessGame() : this(Position.CreateDefault()) { } + public ChessGame() : this(Position.CreateDefault()) + { + } internal ChessGame(Position position) { - if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); + if (!PositionValidation.IsValid(position)) + { + throw new ArgumentException("Invalid standard chess position.", nameof(position)); + } + this.position = position; initialFen = FenStrings.Format(position); - var moves = LegalMoves(position); - string key = RepetitionKey(position, moves); + var legalMoves = GenerateLegalMoves(position); + string key = CreateRepetitionKey(position, legalMoves); keys.Add(key); repetitions.Add(key, 1); - Outcome = Evaluate(position, moves.Count, 1); + Outcome = EvaluateOutcome(position, legalMoves.Count, 1); } private ChessGame(ChessGame original) @@ -59,7 +82,10 @@ private ChessGame(ChessGame original) history.AddRange(original.history); undo.AddRange(original.undo); keys.AddRange(original.keys); - foreach (var pair in original.repetitions) repetitions.Add(pair.Key, pair.Value); + foreach (var pair in original.repetitions) + { + repetitions.Add(pair.Key, pair.Value); + } } public ChessGame Clone() => new(this); @@ -76,11 +102,19 @@ private ChessGame(ChessGame original) /// Copies the board in a1-to-h8 order. public void CopyBoardTo(Span destination) { - if (destination.Length < 64) throw new ArgumentException("Board requires 64 squares.", nameof(destination)); + if (destination.Length < 64) + { + throw new ArgumentException("Board requires 64 squares.", nameof(destination)); + } + for (int i = 0; i < 64; i++) { var piece = position.GetPieceAt(i, Colors.White); - if (!Piece.IsValid(piece)) piece = position.GetPieceAt(i, Colors.Black); + if (!Piece.IsValid(piece)) + { + piece = position.GetPieceAt(i, Colors.Black); + } + destination[i] = Piece.IsValid(piece) ? new(piece.Color.ToPublicColor(), piece.Value.ToPublicPiece()) : ChessPiece.Empty; @@ -90,71 +124,123 @@ public void CopyBoardTo(Span destination) /// Gets the piece on a square, or if it is vacant. public ChessPiece GetPiece(Square square) { - if (!square.IsValid) throw new ArgumentException("A valid square is required.", nameof(square)); + if (!square.IsValid) + { + throw new ArgumentException("A valid square is required.", nameof(square)); + } + var piece = position.GetPieceAt(square.Index, Colors.White); - if (!Piece.IsValid(piece)) piece = position.GetPieceAt(square.Index, Colors.Black); + if (!Piece.IsValid(piece)) + { + piece = position.GetPieceAt(square.Index, Colors.Black); + } + return Piece.IsValid(piece) ? new(piece.Color.ToPublicColor(), piece.Value.ToPublicPiece()) : ChessPiece.Empty; } /// Snapshot of playable moves; empty after completion. Does not mutate the game. - public IReadOnlyList GetLegalMoves() => IsFinished - ? Array.Empty() - : Array.AsReadOnly(LegalMoves(position).Select(ToPublicMove).ToArray()); + public IReadOnlyList GetLegalMoves() + { + if (IsFinished) + { + return Array.Empty(); + } - public bool HasLegalMoves => !IsFinished && LegalMoves(position).Count != 0; + return Array.AsReadOnly(GenerateLegalMoves(position).Select(ToPublicMove).ToArray()); + } + + public bool HasLegalMoves => !IsFinished && GenerateLegalMoves(position).Count != 0; public MoveResult MakeMove(CoordinateMove move) { - if (IsFinished || !TryFindMove(move, out int encoded)) return MoveResult.None; - var next = position; - MoveDriver.MakeMove(ref next, encoded); - next.SwitchColor(); - var replies = LegalMoves(next); - string key = RepetitionKey(next, replies); - int count = repetitions.GetValueOrDefault(key) + 1; - var outcome = Evaluate(next, replies.Count, count); + if (IsFinished || !TryFindMove(move, out int encodedMove)) + { + return MoveResult.None; + } + + var nextPosition = position; + MoveDriver.MakeMove(ref nextPosition, encodedMove); + nextPosition.SwitchColor(); + + var replies = GenerateLegalMoves(nextPosition); + string key = CreateRepetitionKey(nextPosition, replies); + int repetitionCount = repetitions.GetValueOrDefault(key) + 1; + var outcome = EvaluateOutcome(nextPosition, replies.Count, repetitionCount); + MoveResult result = MoveResult.Move; - if (BinaryMoveOps.DecodeCapture(encoded) != 0) result |= MoveResult.Capture; - if (next.IsKingChecked()) result |= MoveResult.Check; - if (outcome.Reason == FinishReason.Checkmate) result |= MoveResult.Checkmate; - if (outcome.Reason == FinishReason.Stalemate) result |= MoveResult.Stalemate; + if (BinaryMoveOps.DecodeCapture(encodedMove) != 0) + { + result |= MoveResult.Capture; + } + + if (nextPosition.IsKingChecked()) + { + result |= MoveResult.Check; + } + + if (outcome.Reason == FinishReason.Checkmate) + { + result |= MoveResult.Checkmate; + } - var entry = new MoveHistoryEntry(move, ToFen(), FenStrings.FormatUnchecked(next), result); + if (outcome.Reason == FinishReason.Stalemate) + { + result |= MoveResult.Stalemate; + } + + var entry = new MoveHistoryEntry(move, ToFen(), FenStrings.FormatUnchecked(nextPosition), result); undo.Add(position); history.Add(entry); keys.Add(key); - repetitions[key] = count; - position = next; + repetitions[key] = repetitionCount; + position = nextPosition; Outcome = outcome; + return result; } /// Undo the last successful move, also clearing a later draw claim. public bool UndoMove() { - if (undo.Count == 0) return false; + if (undo.Count == 0) + { + return false; + } + string key = keys[^1]; - if (--repetitions[key] == 0) repetitions.Remove(key); + 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); - Outcome = Evaluate(position, LegalMoves(position).Count, RepetitionCount); + Outcome = EvaluateOutcome(position, GenerateLegalMoves(position).Count, RepetitionCount); DrawIntendedMove = null; + return true; } /// Claims available after an intended legal move, without executing it. public DrawClaim GetAvailableDrawClaims(CoordinateMove intendedMove) { - if (IsFinished || !TryFindMove(intendedMove, out int encoded)) return DrawClaim.None; - var next = position; - MoveDriver.MakeMove(ref next, encoded); - next.SwitchColor(); - var moves = LegalMoves(next); - return Claims(next, repetitions.GetValueOrDefault(RepetitionKey(next, moves)) + 1); + if (IsFinished || !TryFindMove(intendedMove, out int encodedMove)) + { + return DrawClaim.None; + } + + var nextPosition = position; + MoveDriver.MakeMove(ref nextPosition, encodedMove); + nextPosition.SwitchColor(); + + var legalMoves = GenerateLegalMoves(nextPosition); + string key = CreateRepetitionKey(nextPosition, legalMoves); + int repetitionCount = repetitions.GetValueOrDefault(key) + 1; + return CalculateDrawClaims(nextPosition, repetitionCount); } /// A valid intended-move claim ends the game at its current board; the move is not played. @@ -165,89 +251,143 @@ public bool ClaimDraw(DrawClaim reason, CoordinateMove? intendedMove = null) return false; } - DrawClaim available = intendedMove is { } move ? GetAvailableDrawClaims(move) : AvailableDrawClaims; + DrawClaim available = intendedMove is { } move + ? GetAvailableDrawClaims(move) + : AvailableDrawClaims; if ((available & reason) == 0) { return false; } - Outcome = Draw(reason == DrawClaim.ThreefoldRepetition ? FinishReason.ThreefoldRepetition : FinishReason.FiftyMoveRule); + FinishReason finishReason = reason == DrawClaim.ThreefoldRepetition + ? FinishReason.ThreefoldRepetition + : FinishReason.FiftyMoveRule; + Outcome = Draw(finishReason); DrawIntendedMove = intendedMove; return true; } - private bool TryFindMove(CoordinateMove move, out int encoded) + private bool TryFindMove(CoordinateMove move, out int encodedMove) { - encoded = 0; - if (!move.IsValid) return false; - foreach (int candidate in LegalMoves(position)) + encodedMove = 0; + if (!move.IsValid) { - if (ToPublicMove(candidate) != move) continue; - encoded = candidate; + return false; + } + + foreach (int candidate in GenerateLegalMoves(position)) + { + if (ToPublicMove(candidate) != move) + { + continue; + } + + encodedMove = candidate; return true; } + return false; } - private static CoordinateMove ToPublicMove(int move) => new(new Square(BinaryMoveOps.DecodeSrc(move)), - new Square(BinaryMoveOps.DecodeTrg(move)), BinaryMoveOps.DecodePromotion(move).ToPublicPromotion()); + private static CoordinateMove ToPublicMove(int encodedMove) => new( + new Square(BinaryMoveOps.DecodeSrc(encodedMove)), + new Square(BinaryMoveOps.DecodeTrg(encodedMove)), + BinaryMoveOps.DecodePromotion(encodedMove).ToPublicPromotion()); - private static List LegalMoves(Position p) + private static List GenerateLegalMoves(Position position) { Span buffer = stackalloc int[MoveGen.MaxMoves]; - int written = MoveGen.WriteMoves(ref p, p.color, buffer); - List legal = []; + int written = MoveGen.WriteMoves(ref position, position.color, buffer); + List legalMoves = []; for (int i = 0; i < written; i++) { - var next = p; + var nextPosition = position; // Move legality is independent of FEN counters. - MoveDriver.MakeMove(ref next, buffer[i], updateCounters: false); - if (!next.IsKingChecked(p.color)) legal.Add(buffer[i]); + MoveDriver.MakeMove(ref nextPosition, buffer[i], updateCounters: false); + if (!nextPosition.IsKingChecked(position.color)) + { + legalMoves.Add(buffer[i]); + } } - return legal; + + return legalMoves; } - private static string RepetitionKey(Position p, List moves) + private static string CreateRepetitionKey(Position position, List legalMoves) { - if (!moves.Any(m => BinaryMoveOps.DecodeEnpassant(m) != 0)) p.enpassant = Squares.Empty; - string fen = FenStrings.FormatUnchecked(p); - return fen[..fen.LastIndexOf(' ', fen.LastIndexOf(' ') - 1)]; + if (!legalMoves.Any(move => BinaryMoveOps.DecodeEnpassant(move) != 0)) + { + position.enpassant = Squares.Empty; + } + + string fen = FenStrings.FormatUnchecked(position); + int fullmoveSeparator = fen.LastIndexOf(' '); + int halfmoveSeparator = fen.LastIndexOf(' ', fullmoveSeparator - 1); + return fen[..halfmoveSeparator]; } - private static DrawClaim Claims(Position p, int count) => - (count >= 3 ? DrawClaim.ThreefoldRepetition : DrawClaim.None) - | (p.halfMoveClock >= 100 ? DrawClaim.FiftyMoveRule : DrawClaim.None); + private static DrawClaim CalculateDrawClaims(Position position, int repetitionCount) => + (repetitionCount >= 3 ? DrawClaim.ThreefoldRepetition : DrawClaim.None) + | (position.halfMoveClock >= 100 ? DrawClaim.FiftyMoveRule : DrawClaim.None); - private static GameOutcome Evaluate(Position p, int legalCount, int repetitions) + private static GameOutcome EvaluateOutcome(Position position, int legalMoveCount, int repetitionCount) { - if (legalCount == 0) + if (legalMoveCount == 0) { - if (!p.IsKingChecked()) return Draw(FinishReason.Stalemate); - PieceColor winner = Colors.Mirror(p.color).ToPublicColor(); - return new(winner == PieceColor.White ? GameResult.WhiteWin : GameResult.BlackWin, winner, FinishReason.Checkmate); + if (!position.IsKingChecked()) + { + return Draw(FinishReason.Stalemate); + } + + PieceColor winner = Colors.Mirror(position.color).ToPublicColor(); + GameResult result = winner == PieceColor.White ? GameResult.WhiteWin : GameResult.BlackWin; + return new(result, winner, FinishReason.Checkmate); } - if (IsBasicDeadPosition(p)) return Draw(FinishReason.DeadPosition); - if (repetitions >= 5) return Draw(FinishReason.FivefoldRepetition); - if (p.halfMoveClock >= 150) return Draw(FinishReason.SeventyFiveMoveRule); + + if (IsBasicDeadPosition(position)) + { + return Draw(FinishReason.DeadPosition); + } + + if (repetitionCount >= 5) + { + return Draw(FinishReason.FivefoldRepetition); + } + + if (position.halfMoveClock >= 150) + { + return Draw(FinishReason.SeventyFiveMoveRule); + } + return GameOutcome.Ongoing; } private static GameOutcome Draw(FinishReason reason) => new(GameResult.Draw, PieceColor.None, reason); - private static bool IsBasicDeadPosition(Position p) + private static bool IsBasicDeadPosition(Position position) { - if ((p.pieceBBs[0] | p.pieceBBs[6] | p.pieceBBs[3] | p.pieceBBs[9] - | p.pieceBBs[4] | p.pieceBBs[10]) != 0) return false; - ulong knights = p.pieceBBs[1] | p.pieceBBs[7]; - ulong bishops = p.pieceBBs[2] | p.pieceBBs[8]; - if (BitOperations.PopCount(knights | bishops) <= 1) return true; + ulong pawns = position.pieceBBs[BitboardIndicies.BPawn] | position.pieceBBs[BitboardIndicies.WPawn]; + ulong rooks = position.pieceBBs[BitboardIndicies.BRook] | position.pieceBBs[BitboardIndicies.WRook]; + ulong queens = position.pieceBBs[BitboardIndicies.BQueen] | position.pieceBBs[BitboardIndicies.WQueen]; + if ((pawns | rooks | queens) != 0) + { + return false; + } + + ulong knights = position.pieceBBs[BitboardIndicies.BKnight] | position.pieceBBs[BitboardIndicies.WKnight]; + ulong bishops = position.pieceBBs[BitboardIndicies.BBishop] | position.pieceBBs[BitboardIndicies.WBishop]; + if (BitOperations.PopCount(knights | bishops) <= 1) + { + return true; + } // Bishops alone, all on one square color. Never infer deadness from inability to force mate. - return knights == 0 && ((bishops & 0x55aa55aa55aa55aaUL) == 0 || (bishops & 0xaa55aa55aa55aa55UL) == 0); + return knights == 0 + && ((bishops & SquareMapping.DARK_SQUARES) == 0 || (bishops & SquareMapping.LIGHT_SQUARES) == 0); } - public static bool TryCreateFromFen(string? fen, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChessGame? chessGame) + public static bool TryCreateFromFen(string? fen, [NotNullWhen(true)] out ChessGame? chessGame) { chessGame = FenStrings.TryParse(fen, out var position) ? new ChessGame(position) : null; return chessGame is not null; diff --git a/src/ChessRealms.Engine/ChessPiece.cs b/src/ChessRealms.Engine/ChessPiece.cs index cc2ba7e..a625667 100644 --- a/src/ChessRealms.Engine/ChessPiece.cs +++ b/src/ChessRealms.Engine/ChessPiece.cs @@ -7,9 +7,14 @@ public readonly record struct ChessPiece public ChessPiece(PieceColor color, PieceValue value) { if (color is not (PieceColor.White or PieceColor.Black)) + { throw new ArgumentException("A piece must be white or black.", nameof(color)); + } + if (value is < PieceValue.Pawn or > PieceValue.King) + { throw new ArgumentException("A valid piece value is required.", nameof(value)); + } Color = color; Value = value; diff --git a/src/ChessRealms.Engine/CoordinateMove.cs b/src/ChessRealms.Engine/CoordinateMove.cs index 745f3f7..281c639 100644 --- a/src/ChessRealms.Engine/CoordinateMove.cs +++ b/src/ChessRealms.Engine/CoordinateMove.cs @@ -9,12 +9,26 @@ public readonly record struct CoordinateMove /// A square or promotion value is invalid. public CoordinateMove(Square source, Square target, PieceValue promotion = PieceValue.None) { - if (!source.IsValid) throw new ArgumentException("A valid source square is required.", nameof(source)); - if (!target.IsValid) throw new ArgumentException("A valid target square is required.", nameof(target)); - if (source == target) throw new ArgumentException("Source and target squares must differ.", nameof(target)); + if (!source.IsValid) + { + throw new ArgumentException("A valid source square is required.", nameof(source)); + } + + if (!target.IsValid) + { + throw new ArgumentException("A valid target square is required.", nameof(target)); + } + + if (source == target) + { + throw new ArgumentException("Source and target squares must differ.", nameof(target)); + } + if (promotion is not (PieceValue.None or PieceValue.Queen or PieceValue.Rook or PieceValue.Bishop or PieceValue.Knight)) + { throw new ArgumentException("Promotion must be a queen, rook, bishop, knight, or none.", nameof(promotion)); + } Source = source; Target = target; @@ -89,13 +103,22 @@ public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) } /// - public override string ToString() => !IsValid ? string.Empty : - SquareOps.ToAbbreviature(Source.Index) + SquareOps.ToAbbreviature(Target.Index) + (Promotion switch + public override string ToString() + { + if (!IsValid) + { + return string.Empty; + } + + string promotionSuffix = Promotion switch { PieceValue.Queen => "q", PieceValue.Rook => "r", PieceValue.Bishop => "b", PieceValue.Knight => "n", _ => string.Empty - }); + }; + + return SquareOps.ToAbbreviature(Source.Index) + SquareOps.ToAbbreviature(Target.Index) + promotionSuffix; + } } diff --git a/src/ChessRealms.Engine/Core/Attacks/BishopAttacks.cs b/src/ChessRealms.Engine/Core/Attacks/BishopAttacks.cs index d103115..5b30d96 100644 --- a/src/ChessRealms.Engine/Core/Attacks/BishopAttacks.cs +++ b/src/ChessRealms.Engine/Core/Attacks/BishopAttacks.cs @@ -18,7 +18,7 @@ internal static unsafe class BishopAttacks public static readonly ulong[] AttackMasks; public static readonly ulong* AttackMasksPtr; - public static readonly int[] RelevantBits = + public static readonly int[] RelevantBits = [ 6, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, @@ -31,7 +31,7 @@ internal static unsafe class BishopAttacks ]; public static readonly int* RelevantBitsPtr; - public static readonly ulong[] MagicNumbers = + public static readonly ulong[] MagicNumbers = [ 0x0002100148088080, 0x24502C0B20620000, @@ -108,13 +108,13 @@ static BishopAttacks() for (int square = 0; square < 64; ++square) { AttackMasks[square] = MaskBishopAttack(square); - + int occupancyIndicies = 1 << RelevantBits[square]; for (int index = 0; index < occupancyIndicies; ++index) { ulong occupancy = Occupancy.CreateAtIndex(index, RelevantBits[square], AttackMasks[square]); - + int magicIndex = (int)((occupancy * MagicNumbers[square]) >> (64 - RelevantBits[square])); ulong mask = MaskBishopSliderAttackOnTheFly(square, occupancy); @@ -162,27 +162,35 @@ public static ulong MaskBishopAttack(int square) r = SquareOps.Rank(square) + 1; f = SquareOps.File(square) + 1; - + while (r <= 6 && f <= 6) + { attacks |= SquareOps.ToBitboard(SquareOps.FromFileRank(f++, r++)); + } r = SquareOps.Rank(square) - 1; f = SquareOps.File(square) + 1; - + while (r >= 1 && f <= 6) + { attacks |= SquareOps.ToBitboard(SquareOps.FromFileRank(f++, r--)); + } r = SquareOps.Rank(square) + 1; f = SquareOps.File(square) - 1; - + while (r <= 6 && f >= 1) + { attacks |= SquareOps.ToBitboard(SquareOps.FromFileRank(f--, r++)); + } r = SquareOps.Rank(square) - 1; f = SquareOps.File(square) - 1; while (r >= 1 && f >= 1) + { attacks |= SquareOps.ToBitboard(SquareOps.FromFileRank(f--, r--)); + } return attacks; } @@ -206,7 +214,9 @@ public static ulong MaskBishopSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f++, r++)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } r = SquareOps.Rank(square) - 1; @@ -217,7 +227,9 @@ public static ulong MaskBishopSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f++, r--)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } r = SquareOps.Rank(square) + 1; @@ -228,7 +240,9 @@ public static ulong MaskBishopSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f--, r++)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } r = SquareOps.Rank(square) - 1; @@ -239,7 +253,9 @@ public static ulong MaskBishopSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f--, r--)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } return attacks; diff --git a/src/ChessRealms.Engine/Core/Attacks/Occupancy.cs b/src/ChessRealms.Engine/Core/Attacks/Occupancy.cs index 4961fb5..372fb98 100644 --- a/src/ChessRealms.Engine/Core/Attacks/Occupancy.cs +++ b/src/ChessRealms.Engine/Core/Attacks/Occupancy.cs @@ -24,7 +24,9 @@ public static ulong CreateAtIndex(int occupancyIndex, int bitsCount, ulong attac attackMask = BitboardOps.PopBitAt(attackMask, square); if ((occupancyIndex & 1 << i) != 0) + { occupancy = BitboardOps.SetBitAt(occupancy, square); + } } return occupancy; diff --git a/src/ChessRealms.Engine/Core/Attacks/RookAttacks.cs b/src/ChessRealms.Engine/Core/Attacks/RookAttacks.cs index 689413c..ea61f09 100644 --- a/src/ChessRealms.Engine/Core/Attacks/RookAttacks.cs +++ b/src/ChessRealms.Engine/Core/Attacks/RookAttacks.cs @@ -18,7 +18,7 @@ internal static unsafe class RookAttacks public static readonly ulong[] AttackMasks; public static readonly ulong* AttackMasksPtr; - public static readonly int[] RelevantBits = + public static readonly int[] RelevantBits = [ 12, 11, 11, 11, 11, 11, 11, 12, 11, 10, 10, 10, 10, 10, 10, 11, @@ -31,7 +31,7 @@ internal static unsafe class RookAttacks ]; public static readonly int* RelevantBitsPtr; - private static readonly ulong[] MagicNumbers = + private static readonly ulong[] MagicNumbers = [ 0x0C80108004400020, 0x0240002000100042, @@ -114,7 +114,7 @@ static RookAttacks() for (int index = 0; index < occupancyIndicies; ++index) { ulong occupancy = Occupancy.CreateAtIndex(index, RelevantBits[square], AttackMasks[square]); - + int magicIndex = (int)((occupancy * MagicNumbers[square]) >> (64 - RelevantBits[square])); ulong mask = MaskRookSliderAttackOnTheFly(square, occupancy); @@ -123,7 +123,7 @@ static RookAttacks() } } - + AttackMasksPtr = (ulong*)GCHandle.Alloc(AttackMasks, GCHandleType.Pinned).AddrOfPinnedObject().ToPointer(); SliderAttacksPtr = (ulong*)GCHandle.Alloc(SliderAttacks, GCHandleType.Pinned).AddrOfPinnedObject().ToPointer(); RelevantBitsPtr = (int*)GCHandle.Alloc(RelevantBits, GCHandleType.Pinned).AddrOfPinnedObject().ToPointer(); @@ -162,7 +162,7 @@ public static ulong MaskRookAttack(int square) ulong attacks = 0UL; - + for (int r = SquareOps.Rank(square) + 1, f = SquareOps.File(square); r <= 6; ++r) { attacks |= SquareOps.ToBitboard(SquareOps.FromFileRank(f, r)); @@ -203,7 +203,9 @@ public static ulong MaskRookSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f, r)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } for (r = SquareOps.Rank(square) - 1; r >= 0; --r) @@ -211,7 +213,9 @@ public static ulong MaskRookSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f, r)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } r = SquareOps.Rank(square); @@ -221,7 +225,9 @@ public static ulong MaskRookSliderAttackOnTheFly(int square, ulong blockers) ulong squareAtBoard = SquareOps.ToBitboard(SquareOps.FromFileRank(f, r)); attacks |= squareAtBoard; if ((squareAtBoard & blockers) != 0) + { break; + } } for (f = SquareOps.File(square) - 1; f >= 0; --f) @@ -229,7 +235,9 @@ public static ulong MaskRookSliderAttackOnTheFly(int square, ulong blockers) ulong board = SquareOps.ToBitboard(SquareOps.FromFileRank(f, r)); attacks |= board; if ((board & blockers) != 0) + { break; + } } return attacks; diff --git a/src/ChessRealms.Engine/Core/Movements/MoveDriver.cs b/src/ChessRealms.Engine/Core/Movements/MoveDriver.cs index 029252f..1683dd0 100644 --- a/src/ChessRealms.Engine/Core/Movements/MoveDriver.cs +++ b/src/ChessRealms.Engine/Core/Movements/MoveDriver.cs @@ -27,7 +27,9 @@ public static void MakeMove(ref Position position, int move, bool updateCounters position.halfMoveClock = BinaryMoveOps.DecodeSrcPiece(move) == Pieces.Pawn || BinaryMoveOps.DecodeCapture(move) != 0 ? 0 : position.halfMoveClock + 1; if (BinaryMoveOps.DecodeSrcColor(move) == Colors.Black) + { position.fullMoveCount = position.fullMoveCount + 1; + } } position.enpassant = Squares.Empty; diff --git a/src/ChessRealms.Engine/Core/Types/Position.cs b/src/ChessRealms.Engine/Core/Types/Position.cs index f5d168e..faf6797 100644 --- a/src/ChessRealms.Engine/Core/Types/Position.cs +++ b/src/ChessRealms.Engine/Core/Types/Position.cs @@ -9,7 +9,6 @@ namespace ChessRealms.Engine.Core.Types; - /// A value snapshot. Assignment copies bitboards; its BigInteger counters are immutable. internal struct Position : IEquatable { @@ -25,8 +24,22 @@ internal struct Position : IEquatable public bool Equals(Position other) { - for (int i = 0; i < 12; i++) if (pieceBBs[i] != other.pieceBBs[i]) return false; - for (int i = 0; i < 3; i++) if (blockers[i] != other.blockers[i]) return false; + for (int i = 0; i < 12; i++) + { + if (pieceBBs[i] != other.pieceBBs[i]) + { + return false; + } + } + + for (int i = 0; i < 3; i++) + { + if (blockers[i] != other.blockers[i]) + { + return false; + } + } + return color == other.color && castlings == other.castlings && enpassant == other.enpassant && fullMoveCount == other.fullMoveCount && halfMoveClock == other.halfMoveClock; } @@ -35,10 +48,21 @@ public bool Equals(Position other) public override int GetHashCode() { HashCode hash = new(); - for (int i = 0; i < 12; i++) hash.Add(pieceBBs[i]); - for (int i = 0; i < 3; i++) hash.Add(blockers[i]); - hash.Add(color); hash.Add(castlings); hash.Add(enpassant); - hash.Add(fullMoveCount); hash.Add(halfMoveClock); + for (int i = 0; i < 12; i++) + { + hash.Add(pieceBBs[i]); + } + + for (int i = 0; i < 3; i++) + { + hash.Add(blockers[i]); + } + + hash.Add(color); + hash.Add(castlings); + hash.Add(enpassant); + hash.Add(fullMoveCount); + hash.Add(halfMoveClock); return hash.ToHashCode(); } @@ -59,8 +83,15 @@ public Position() public Piece GetPieceAt(int square, int color) { - if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Squares.IsValid(square)) + { + throw new ArgumentOutOfRangeException(nameof(square)); + } + + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } int bbIndex = BBIndex(Pieces.Pawn, color); int bbLastIndex = BBIndex(Pieces.King, color); @@ -84,9 +115,16 @@ public Piece GetPieceAt(int square, int color) internal void SetPieceAt(int square, int piece, int color) { - if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); + if (!Squares.IsValid(square)) + { + throw new ArgumentOutOfRangeException(nameof(square)); + } + DebugHelper.Assert.IsValidPiece(piece); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } int bbIndex = BBIndex(piece, color); Debug.Assert(IsValidBBIndex(bbIndex)); @@ -98,9 +136,16 @@ internal void SetPieceAt(int square, int piece, int color) internal void PopPieceAt(int square, int piece, int color) { - if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); + if (!Squares.IsValid(square)) + { + throw new ArgumentOutOfRangeException(nameof(square)); + } + DebugHelper.Assert.IsValidPiece(piece); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } int bbIndex = BBIndex(piece, color); Debug.Assert(IsValidBBIndex(bbIndex)); @@ -112,8 +157,15 @@ internal void PopPieceAt(int square, int piece, int color) internal void PopPieceAt(int square, int color) { - if (!Squares.IsValid(square)) throw new ArgumentOutOfRangeException(nameof(square)); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Squares.IsValid(square)) + { + throw new ArgumentOutOfRangeException(nameof(square)); + } + + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } int i = BBIndex(Pieces.Pawn, color); @@ -138,7 +190,10 @@ internal void MovePiece(int srcSquare, int trgSquare, int color, int piece) internal static int BBIndex(int piece, int color) { DebugHelper.Assert.IsValidPiece(piece); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } return (color * 6) + piece; } @@ -147,7 +202,10 @@ internal static int BBIndex(int piece, int color) internal static int PieceFromBBIndex(int bbIndex, int color) { Debug.Assert(IsValidBBIndex(bbIndex)); - if (!Colors.IsValid(color)) throw new ArgumentOutOfRangeException(nameof(color)); + if (!Colors.IsValid(color)) + { + throw new ArgumentOutOfRangeException(nameof(color)); + } return bbIndex - color * 6; } @@ -160,7 +218,11 @@ private static bool IsValidBBIndex(int bbIndex) public bool IsKingChecked() { - if (!PositionValidation.IsValid(this)) throw new InvalidOperationException("Invalid standard chess position."); + if (!PositionValidation.IsValid(this)) + { + throw new InvalidOperationException("Invalid standard chess position."); + } + return IsKingChecked(color); } @@ -168,20 +230,19 @@ internal bool IsKingChecked(int kingColor) { if (kingColor == Colors.Black) { - int ks = BitboardOps.Lsb(pieceBBs[BitboardIndicies.BKing]); + int kingSquare = BitboardOps.Lsb(pieceBBs[BitboardIndicies.BKing]); - return IsSquareAttackedByWhite(ks); + return IsSquareAttackedByWhite(kingSquare); } else { - int ks = BitboardOps.Lsb(pieceBBs[BitboardIndicies.WKing]); + int kingSquare = BitboardOps.Lsb(pieceBBs[BitboardIndicies.WKing]); - return IsSquareAttackedByBlack(ks); + return IsSquareAttackedByBlack(kingSquare); } } - // Should run a bit faster than universal version - // that calculates Bitboard indicies. + // Color-specific helpers address the opponent's bitboards directly. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal bool IsSquareAttackedByWhite(int square) { @@ -300,61 +361,65 @@ internal bool IsAttackedByBlackKing(int square) } #endregion - // Position assignment copies the inline bitboards; BigInteger is immutable. - /// - /// Creates default position filled with setuped pieces at its default positions. + /// Creates the standard starting position. /// - /// Default Position. public static Position CreateDefault() { - const ulong bPawns = SquareMapping.RANK_8 >> 8; - const ulong bKnights = (1ul << Squares.b8) | (1ul << Squares.g8); - const ulong bBishops = (1ul << Squares.c8) | (1ul << Squares.f8); - const ulong bRooks = (1ul << Squares.a8) | (1ul << Squares.h8); - const ulong bQueen = 1ul << Squares.d8; - const ulong bKing = 1ul << Squares.e8; - const ulong bAll = SquareMapping.RANK_8 | bPawns; - - const ulong wPawns = SquareMapping.RANK_1 << 8; - const ulong wKnights = (1ul << Squares.b1) | (1ul << Squares.g1); - const ulong wBishops = (1ul << Squares.c1) | (1ul << Squares.f1); - const ulong wRooks = (1ul << Squares.a1) | (1ul << Squares.h1); - const ulong wQueen = 1ul << Squares.d1; - const ulong wKing = 1ul << Squares.e1; - const ulong wAll = SquareMapping.RANK_1 | wPawns; + const ulong bPawns = SquareMapping.RANK_8 >> 8; + const ulong bKnights = (1ul << Squares.b8) | (1ul << Squares.g8); + const ulong bBishops = (1ul << Squares.c8) | (1ul << Squares.f8); + const ulong bRooks = (1ul << Squares.a8) | (1ul << Squares.h8); + const ulong bQueen = 1ul << Squares.d8; + const ulong bKing = 1ul << Squares.e8; + const ulong bAll = SquareMapping.RANK_8 | bPawns; + + const ulong wPawns = SquareMapping.RANK_1 << 8; + const ulong wKnights = (1ul << Squares.b1) | (1ul << Squares.g1); + const ulong wBishops = (1ul << Squares.c1) | (1ul << Squares.f1); + const ulong wRooks = (1ul << Squares.a1) | (1ul << Squares.h1); + const ulong wQueen = 1ul << Squares.d1; + const ulong wKing = 1ul << Squares.e1; + const ulong wAll = SquareMapping.RANK_1 | wPawns; Position position = new(); - position.pieceBBs[BitboardIndicies.BPawn] = bPawns; + position.pieceBBs[BitboardIndicies.BPawn] = bPawns; position.pieceBBs[BitboardIndicies.BKnight] = bKnights; position.pieceBBs[BitboardIndicies.BBishop] = bBishops; - position.pieceBBs[BitboardIndicies.BRook] = bRooks; - position.pieceBBs[BitboardIndicies.BQueen] = bQueen; - position.pieceBBs[BitboardIndicies.BKing] = bKing; + position.pieceBBs[BitboardIndicies.BRook] = bRooks; + position.pieceBBs[BitboardIndicies.BQueen] = bQueen; + position.pieceBBs[BitboardIndicies.BKing] = bKing; - position.pieceBBs[BitboardIndicies.WPawn] = wPawns; + position.pieceBBs[BitboardIndicies.WPawn] = wPawns; position.pieceBBs[BitboardIndicies.WKnight] = wKnights; position.pieceBBs[BitboardIndicies.WBishop] = wBishops; - position.pieceBBs[BitboardIndicies.WRook] = wRooks; - position.pieceBBs[BitboardIndicies.WQueen] = wQueen; - position.pieceBBs[BitboardIndicies.WKing] = wKing; + position.pieceBBs[BitboardIndicies.WRook] = wRooks; + position.pieceBBs[BitboardIndicies.WQueen] = wQueen; + position.pieceBBs[BitboardIndicies.WKing] = wKing; - position.blockers[BitboardIndicies.BBlockers] = bAll; - position.blockers[BitboardIndicies.WBlockers] = wAll; + position.blockers[BitboardIndicies.BBlockers] = bAll; + position.blockers[BitboardIndicies.WBlockers] = wAll; position.blockers[BitboardIndicies.AllBlockers] = bAll | wAll; - position.color = Colors.White; - position.castlings = Castlings.All; - position.enpassant = Squares.Empty; - position.fullMoveCount = 1; - position.halfMoveClock = 0; + position.color = Colors.White; + position.castlings = Castlings.All; + position.enpassant = Squares.Empty; + position.fullMoveCount = 1; + position.halfMoveClock = 0; return position; } } + [InlineArray(12)] -internal struct PieceBoards { private ulong first; } +internal struct PieceBoards +{ + private ulong first; +} [InlineArray(3)] -internal struct OccupancyBoards { private ulong first; } +internal struct OccupancyBoards +{ + private ulong first; +} diff --git a/src/ChessRealms.Engine/Core/Types/PositionValidation.cs b/src/ChessRealms.Engine/Core/Types/PositionValidation.cs index 8b674c1..68de67b 100644 --- a/src/ChessRealms.Engine/Core/Types/PositionValidation.cs +++ b/src/ChessRealms.Engine/Core/Types/PositionValidation.cs @@ -6,56 +6,114 @@ namespace ChessRealms.Engine.Core.Types; /// Local standard-chess invariants, without proving historical reachability. internal static class PositionValidation { - internal static bool IsValid(Position p) + internal static bool IsValid(Position position) { - if (!Colors.IsValid(p.color) || (p.castlings & ~Castlings.All) != 0 - || p.halfMoveClock < 0 || p.fullMoveCount < 1 - || (p.enpassant != Squares.Empty && !Squares.IsValid(p.enpassant))) return false; - ulong all = 0; + if (!Colors.IsValid(position.color) || (position.castlings & ~Castlings.All) != 0 + || position.halfMoveClock < 0 || position.fullMoveCount < 1 + || (position.enpassant != Squares.Empty && !Squares.IsValid(position.enpassant))) + { + return false; + } + + ulong allOccupied = 0; for (int color = 0; color < 2; color++) { - ulong occupied = 0; + ulong occupiedByColor = 0; for (int piece = 0; piece < 6; piece++) { - ulong bb = p.pieceBBs[color * 6 + piece]; - if ((all & bb) != 0) return false; - occupied |= bb; - all |= bb; + ulong pieceBoard = position.pieceBBs[color * 6 + piece]; + if ((allOccupied & pieceBoard) != 0) + { + return false; + } + + occupiedByColor |= pieceBoard; + allOccupied |= pieceBoard; + } + + if (occupiedByColor != position.blockers[color] || BitOperations.PopCount(occupiedByColor) > 16) + { + return false; } - if (occupied != p.blockers[color] || BitOperations.PopCount(occupied) > 16) return false; - ulong pawns = p.pieceBBs[color * 6]; + + ulong pawns = position.pieceBBs[color * 6 + Pieces.Pawn]; int pawnCount = BitOperations.PopCount(pawns); - if (pawnCount > 8 || (pawns & 0xff000000000000ffUL) != 0 - || BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.King]) != 1) return false; + if (pawnCount > 8 || (pawns & (SquareMapping.RANK_1 | SquareMapping.RANK_8)) != 0 + || BitOperations.PopCount(position.pieceBBs[color * 6 + Pieces.King]) != 1) + { + return false; + } + // Extra pieces require enough missing pawns to account for promotions. - int promotions = System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Knight]) - 2) - + System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Rook]) - 2) - + System.Math.Max(0, BitOperations.PopCount(p.pieceBBs[color * 6 + Pieces.Queen]) - 1); - ulong bishops = p.pieceBBs[color * 6 + Pieces.Bishop]; - promotions += System.Math.Max(0, BitOperations.PopCount(bishops & 0x55aa55aa55aa55aaUL) - 1) - + System.Math.Max(0, BitOperations.PopCount(bishops & 0xaa55aa55aa55aa55UL) - 1); - if (promotions > 8 - pawnCount) return false; + int requiredPromotions = System.Math.Max(0, BitOperations.PopCount(position.pieceBBs[color * 6 + Pieces.Knight]) - 2) + + System.Math.Max(0, BitOperations.PopCount(position.pieceBBs[color * 6 + Pieces.Rook]) - 2) + + System.Math.Max(0, BitOperations.PopCount(position.pieceBBs[color * 6 + Pieces.Queen]) - 1); + ulong bishops = position.pieceBBs[color * 6 + Pieces.Bishop]; + requiredPromotions += System.Math.Max(0, BitOperations.PopCount(bishops & SquareMapping.DARK_SQUARES) - 1) + + System.Math.Max(0, BitOperations.PopCount(bishops & SquareMapping.LIGHT_SQUARES) - 1); + if (requiredPromotions > 8 - pawnCount) + { + return false; + } } - if (all != p.blockers[Colors.None]) return false; + + if (allOccupied != position.blockers[Colors.None]) + { + return false; + } + // Also excludes adjacent kings and positions where both kings are in check. - if (p.IsKingChecked(Colors.Mirror(p.color))) return false; + if (position.IsKingChecked(Colors.Mirror(position.color))) + { + return false; + } + + return HasValidCastlingRights(position) && HasValidEnPassant(position, allOccupied); + } + + private static bool HasValidCastlingRights(Position position) + { for (int i = 0; i < 4; i++) { - if ((p.castlings & (1 << i)) == 0) continue; + if ((position.castlings & (1 << i)) == 0) + { + continue; + } + int color = i < 2 ? Colors.White : Colors.Black; - int rank = i < 2 ? 0 : 56; - if (p.GetPieceAt(rank + 4, color).Value != Pieces.King - || p.GetPieceAt(rank + (i % 2 == 0 ? 7 : 0), color).Value != Pieces.Rook) return false; + int rankStart = i < 2 ? Squares.a1 : Squares.a8; + int kingSquare = rankStart + 4; + int rookSquare = rankStart + (i % 2 == 0 ? 7 : 0); + if (position.GetPieceAt(kingSquare, color).Value != Pieces.King + || position.GetPieceAt(rookSquare, color).Value != Pieces.Rook) + { + return false; + } } - if (p.enpassant != Squares.Empty) + + return true; + } + + private static bool HasValidEnPassant(Position position, ulong allOccupied) + { + if (position.enpassant == Squares.Empty) { - int target = p.enpassant; - int direction = p.color == Colors.White ? -8 : 8; - if (target / 8 != (p.color == Colors.White ? 5 : 2) - || (all & (1UL << target)) != 0 || (all & (1UL << (target - direction))) != 0 - || p.GetPieceAt(target + direction, Colors.Mirror(p.color)).Value != Pieces.Pawn - || p.halfMoveClock != 0) return false; + return true; } + + int target = position.enpassant; + int direction = position.color == Colors.White ? Directions.South : Directions.North; + int expectedRank = position.color == Colors.White ? 5 : 2; + int pawnSource = target - direction; + int pawnDestination = target + direction; + if (target / 8 != expectedRank + || (allOccupied & (1UL << target)) != 0 || (allOccupied & (1UL << pawnSource)) != 0 + || position.GetPieceAt(pawnDestination, Colors.Mirror(position.color)).Value != Pieces.Pawn + || position.halfMoveClock != 0) + { + return false; + } + return true; } } diff --git a/src/ChessRealms.Engine/Debugs/DebugHelper.cs b/src/ChessRealms.Engine/Debugs/DebugHelper.cs index 9c919ca..070f230 100644 --- a/src/ChessRealms.Engine/Debugs/DebugHelper.cs +++ b/src/ChessRealms.Engine/Debugs/DebugHelper.cs @@ -6,13 +6,13 @@ namespace ChessRealms.Engine.Debugs; internal static partial class DebugHelper -{ +{ public const string DEBUG = "DEBUG"; public static string MoveToString(int move) { var sb = new StringBuilder(); - + var srcP = BinaryMoveOps.DecodeSrcPiece(move); var srcC = BinaryMoveOps.DecodeSrcColor(move); @@ -30,12 +30,17 @@ public static string MoveToString(int move) _ => '*' }; - if (srcC == Colors.White) piece = char.ToUpper(piece); + if (srcC == Colors.White) + { + piece = char.ToUpper(piece); + } sb.Append(piece); - - if (cap == 1) + + if (cap == 1) + { sb.Append('x'); + } sb.Append(SquareOps.ToAbbreviature(trg)); diff --git a/src/ChessRealms.Engine/GameOutcome.cs b/src/ChessRealms.Engine/GameOutcome.cs index e912ef1..7efed8b 100644 --- a/src/ChessRealms.Engine/GameOutcome.cs +++ b/src/ChessRealms.Engine/GameOutcome.cs @@ -1,10 +1,27 @@ namespace ChessRealms.Engine; -public enum GameState { Active, Check, Finished } -public enum GameResult { Ongoing, WhiteWin, BlackWin, Draw } +public enum GameState +{ + Active, + Check, + Finished +} + +public enum GameResult +{ + Ongoing, + WhiteWin, + BlackWin, + Draw +} [Flags] -public enum DrawClaim { None = 0, ThreefoldRepetition = 1, FiftyMoveRule = 2 } +public enum DrawClaim +{ + None = 0, + ThreefoldRepetition = 1, + FiftyMoveRule = 2 +} public readonly record struct GameOutcome(GameResult Result, PieceColor Winner, FinishReason Reason) { diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index 2633eca..0221e17 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -33,7 +33,7 @@ internal static GameRecord CreateFromGame(ChessGame chessGame) ArgumentNullException.ThrowIfNull(chessGame); var fen = chessGame.InitialFen; - var moves = chessGame.History.Select(x => x.Move).ToArray(); + var moves = chessGame.History.Select(entry => entry.Move).ToArray(); DrawClaimRecord? drawClaim = chessGame.Outcome.Reason switch { FinishReason.ThreefoldRepetition => new(ChessRealms.Engine.DrawClaim.ThreefoldRepetition, diff --git a/src/ChessRealms.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs index ed58d85..810f344 100644 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ b/src/ChessRealms.Engine/Parsing/FenStrings.cs @@ -18,6 +18,7 @@ internal static bool TryParse(string? fen, out Position position) position = candidate; return true; } + position = new(); return false; } @@ -27,61 +28,146 @@ internal static bool TryParse(string? fen, out Position position) internal static bool TryParseSyntax(string? fen, out Position position) { position = new(); - if (fen is null) return false; + if (fen is null) + { + return false; + } + string[] fields = fen.Split(' '); - if (fields.Length != 6 || fields.Any(string.IsNullOrEmpty)) return false; - var ranks = fields[0].Split('/'); - if (ranks.Length != 8) return false; + if (fields.Length != 6 || fields.Any(string.IsNullOrEmpty)) + { + return false; + } + Position candidate = new(); + if (!TryParsePiecePlacement(fields[0], ref candidate)) + { + return false; + } + + if (fields[1] is not ("w" or "b")) + { + return false; + } + + candidate.color = fields[1] == "w" ? Colors.White : Colors.Black; + if (!TryParseCastlingRights(fields[2], out candidate.castlings)) + { + return false; + } + + if (!TryParseEnPassantSquare(fields[3], out candidate.enpassant)) + { + return false; + } + + if (!TryParseCounter(fields[4], out candidate.halfMoveClock) + || !TryParseCounter(fields[5], out candidate.fullMoveCount) + || candidate.fullMoveCount == 0) + { + return false; + } + + position = candidate; + return true; + } + + private static bool TryParsePiecePlacement(string text, ref Position position) + { + var ranks = text.Split('/'); + if (ranks.Length != 8) + { + return false; + } + for (int rank = 0; rank < 8; rank++) { int file = 0; - bool previousDigit = false; - foreach (char c in ranks[rank]) + bool previousWasDigit = false; + foreach (char symbol in ranks[rank]) { - if (c is >= '1' and <= '8') + if (symbol is >= '1' and <= '8') { - if (previousDigit) return false; - file += c - '0'; - previousDigit = true; + if (previousWasDigit) + { + return false; + } + + file += symbol - '0'; + previousWasDigit = true; } else { - int piece = "pnbrqk".IndexOf(char.ToLowerInvariant(c)); - if (piece < 0 || !"pnbrqkPNBRQK".Contains(c) || file >= 8) return false; - candidate.SetPieceAt((7 - rank) * 8 + file++, piece, char.IsUpper(c) ? Colors.White : Colors.Black); - previousDigit = false; + int piece = "pnbrqk".IndexOf(char.ToLowerInvariant(symbol)); + if (piece < 0 || !"pnbrqkPNBRQK".Contains(symbol) || file >= 8) + { + return false; + } + + int square = (7 - rank) * 8 + file; + int color = char.IsUpper(symbol) ? Colors.White : Colors.Black; + file++; + position.SetPieceAt(square, piece, color); + previousWasDigit = false; + } + + if (file > 8) + { + return false; } - if (file > 8) return false; } - if (file != 8) return false; + + if (file != 8) + { + return false; + } } - if (fields[1] is not ("w" or "b")) return false; - candidate.color = fields[1] == "w" ? Colors.White : Colors.Black; - if (fields[2] != "-") + + return true; + } + + private static bool TryParseCastlingRights(string text, out int castlingRights) + { + castlingRights = Castlings.None; + if (text == "-") { - int lastIndex = -1; - foreach (char c in fields[2]) + return true; + } + + int lastIndex = -1; + foreach (char symbol in text) + { + int index = "KQkq".IndexOf(symbol); + if (index <= lastIndex) { - int index = "KQkq".IndexOf(c); - if (index <= lastIndex) return false; - candidate.castlings |= 1 << index; - lastIndex = index; + return false; } + + castlingRights |= 1 << index; + lastIndex = index; + } + + return true; + } + + private static bool TryParseEnPassantSquare(string text, out int square) + { + square = Squares.Empty; + if (text == "-") + { + return true; } - if (fields[3] != "-") + + if (!Square.TryParse(text, out var enPassant) || text[1] is not ('3' or '6')) { - if (!Square.TryParse(fields[3], out var enPassant) - || fields[3][1] is not ('3' or '6')) return false; - candidate.enpassant = enPassant.Index; + return false; } - if (!TryCounter(fields[4], out candidate.halfMoveClock) - || !TryCounter(fields[5], out candidate.fullMoveCount) || candidate.fullMoveCount == 0) return false; - position = candidate; + + square = enPassant.Index; return true; } - private static bool TryCounter(string text, out BigInteger value) + private static bool TryParseCounter(string text, out BigInteger value) { value = 0; return text.All(c => c is >= '0' and <= '9') @@ -90,7 +176,11 @@ private static bool TryCounter(string text, out BigInteger value) internal static string Format(Position position) { - if (!PositionValidation.IsValid(position)) throw new ArgumentException("Invalid standard chess position.", nameof(position)); + if (!PositionValidation.IsValid(position)) + { + throw new ArgumentException("Invalid standard chess position.", nameof(position)); + } + return FormatUnchecked(position); } @@ -99,23 +189,62 @@ internal static string FormatUnchecked(Position position) var result = new StringBuilder(); for (int rank = 7; rank >= 0; rank--) { - int empty = 0; + 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)) { empty++; continue; } - if (empty != 0) { result.Append(empty); empty = 0; } - char c = "pnbrqk"[piece.Value]; - result.Append(piece.Color == Colors.White ? char.ToUpperInvariant(c) : c); + 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('/'); } - if (empty != 0) result.Append(empty); - 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]); - result.Append(' ').Append(position.enpassant == Squares.Empty ? "-" : SquareOps.ToAbbreviature(position.enpassant)); + 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); result.Append(' ').Append(position.halfMoveClock.ToString(CultureInfo.InvariantCulture)); result.Append(' ').Append(position.fullMoveCount.ToString(CultureInfo.InvariantCulture)); return result.ToString(); diff --git a/src/ChessRealms.Engine/Square.cs b/src/ChessRealms.Engine/Square.cs index 3a65d12..cbdcea1 100644 --- a/src/ChessRealms.Engine/Square.cs +++ b/src/ChessRealms.Engine/Square.cs @@ -7,7 +7,11 @@ public readonly record struct Square internal Square(int index) { - if (index is < 0 or >= 64) throw new ArgumentOutOfRangeException(nameof(index)); + if (index is < 0 or >= 64) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + encodedIndex = (byte)(index + 1); } @@ -24,7 +28,10 @@ public static Square Parse(ReadOnlySpan text) public static bool TryParse(ReadOnlySpan text, out Square square) { square = default; - if (text.Length != 2 || text[0] is < 'a' or > 'h' || text[1] is < '1' or > '8') return false; + if (text.Length != 2 || text[0] is < 'a' or > 'h' || text[1] is < '1' or > '8') + { + return false; + } square = new((text[1] - '1') * 8 + text[0] - 'a'); return true; From 1ea5cc44293057495a58b94503202ef98f4f37db Mon Sep 17 00:00:00 2001 From: Hash Date: Mon, 19 Oct 2026 02:13:24 +0300 Subject: [PATCH 5/8] feat(state): add versioned JSON game records --- README.md | 18 +- docs/game-rules-api.md | 79 +++++- .../Json/GameRecordJsonTests.cs | 265 ++++++++++++++++++ .../Json/NotationConverterTests.cs | 167 +++++++++++ .../Utf8NotationTests.cs | 146 ++++++++++ src/ChessRealms.Engine/CoordinateMove.cs | 72 ++++- src/ChessRealms.Engine/GameOutcome.cs | 4 + src/ChessRealms.Engine/GameRecord.cs | 11 + .../Json/CoordinateMoveConverter.cs | 47 ++++ .../Json/DrawClaimConverter.cs | 11 + .../Json/GameRecordJsonSerializer.cs | 65 +++++ .../Json/SquareConverter.cs | 46 +++ src/ChessRealms.Engine/Square.cs | 55 +++- 13 files changed, 978 insertions(+), 8 deletions(-) create mode 100644 src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs create mode 100644 src/ChessRealms.Engine.Tests/Json/NotationConverterTests.cs create mode 100644 src/ChessRealms.Engine.Tests/Utf8NotationTests.cs create mode 100644 src/ChessRealms.Engine/Json/CoordinateMoveConverter.cs create mode 100644 src/ChessRealms.Engine/Json/DrawClaimConverter.cs create mode 100644 src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs create mode 100644 src/ChessRealms.Engine/Json/SquareConverter.cs diff --git a/README.md b/README.md index bc985fc..4bd2388 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ ChessRealms.Engine is a .NET 10 library for standard chess positions and legal play. It uses bitboards and magic attack tables for move generation. The public `ChessGame` API supports FEN import/export, legal moves, promotion, history and -undo, and standard checkmate, stalemate and supported draw rules. The repository -also includes tests, a console game, a perft runner, benchmarks and a magic-number -search tool. +undo, JSON game records, and standard checkmate, stalemate and supported draw +rules. The repository also includes tests, a console game, a perft runner, +benchmarks and a magic-number search tool. It does not provide an AI/search engine, UCI, SAN/PGN, Chess960 or tournament services such as clocks and draw agreements. Dead-position recognition is @@ -53,6 +53,18 @@ promotions require a suffix such as `a7a8q`. The describes ownership, FEN validation, draw claims, repetition and supported rule boundaries. +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); +``` + +JSON records include a schema version, initial FEN, coordinate moves and an +optional draw claim. Import validates the game by replaying its moves. The +restored game preserves history, repetition counts and undo support. + The package intentionally exposes only the high-level types in the `ChessRealms.Engine` namespace. Bitboards, encoded moves, magic tables, raw positions and repository tools are implementation details and may change diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md index 6b3f60e..3ec6227 100644 --- a/docs/game-rules-api.md +++ b/docs/game-rules-api.md @@ -13,6 +13,13 @@ pseudolegal moves are filtered for king safety. checks legality. Promotion has no default queen: `a7a8` is rejected if a promotion is required. `Parse` throws on malformed input; `TryParse` returns false. +- `Square` and `CoordinateMove` also implement `IUtf8SpanParsable` and + `IUtf8SpanFormattable`. For example, `CoordinateMove.Parse("e2e4"u8)` reads + UTF-8 notation and `move.TryFormat(buffer, out int written)` writes it into a + byte span. Formatting accepts an empty format or `G`/`g`; other formats throw + `FormatException`. Parsing and formatting are culture independent and ignore + the provider. An invalid default value or a short destination returns false + with zero bytes written and leaves the destination unchanged. - `GetLegalMoves()` returns a read-only snapshot for the current side, including all promotion choices. It is empty after the game finishes. Move order is unspecified. `MakeMove` returns `MoveResult.None` for illegal moves or a @@ -75,9 +82,75 @@ unavailable draw claim throws `FormatException`. Move errors identify the zero-based index in `record.Moves`. Restoration returns a game only after the whole record succeeds; it does not change the record or the original game. -Records currently remain in memory; JSON serialization is not implemented. -`GameRecord.SchemaVersion` identifies the record schema and is independent of -the package version. +## JSON game records + +Use the static `GameRecord.Serialize` and `GameRecord.Deserialize` methods to +save and load records: + +```csharp +string json = GameRecord.Serialize(game.ExportRecord()); +GameRecord loaded = GameRecord.Deserialize(json); +ChessGame restored = ChessGame.FromRecord(loaded); +``` + +The version-one format is: + +```json +{ + "schemaVersion": 1, + "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "moves": ["e2e4", "e7e5"], + "drawClaim": null +} +``` + +For a successful draw claim, `drawClaim` is an object such as: + +```json +{ + "reason": "threefoldRepetition", + "intendedMove": "f6g8" +} +``` + +That claim is valid only when the recorded history makes it available. The +other supported reason is `fiftyMoveRule`. A claim on the current position +writes `intendedMove: null`; an intended move remains outside `moves` and is +never played during restoration. Automatic endings write `drawClaim: null`. +Reasons are written in camel case and read using the standard case-insensitive +string-enum converter. Numeric reasons, `none` and combinations of claims are +invalid in a game record. + +`schemaVersion`, `initialFen` and `moves` are required and cannot be null. +`drawClaim` may be null or omitted. Within a claim, `reason` is required and +`intendedMove` may be null or omitted. Property names are case sensitive; +property order and whitespace do not matter. Unknown and duplicate properties +are rejected, including inside a claim. Moves are lowercase coordinate strings, +including the explicit promotion suffix. Valid JSON escapes are accepted. + +`GameRecord.SchemaVersion` is the library's constant for the supported format, +currently `1`. Serialization writes it into every document; callers do not +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, +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 +`ArgumentNullException`. + +The JSON document type and serializer options are internal. Use these static +methods for the versioned game-record format; direct +`JsonSerializer.Serialize(record)` is not the persistence API. Standalone +`Square` and `CoordinateMove` values do support `JsonSerializer` directly through +their converter attributes and serialize as string tokens such as `"e4"` and +`"e2e4"`. Invalid default values cannot be serialized. `DrawClaimRecord` uses the +standard object serializer with the converters for its reason and intended +move. No JSON-specific types are added to the public API. ## FEN validation diff --git a/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs b/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs new file mode 100644 index 0000000..f8a3ce6 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/Json/GameRecordJsonTests.cs @@ -0,0 +1,265 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ChessRealms.Engine.Tests.Json; + +internal class GameRecordJsonTests +{ + private const string RepetitionCycle = "g1f3 g8f6 f3g1 f6g8"; + private const string RookEnding = "4k3/8/8/8/8/8/8/R3K3 w - - "; + + // A fixed version-one fixture must remain readable independently of the current serializer. + private const string VersionOneRecord = """ + { + "schemaVersion": 1, + "initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + "moves": ["e2e4", "e7e5"], + "drawClaim": null + } + """; + + [Test] + public void VersionOne_ImportsFixedFixtureAndExportsExpectedFields() + { + var record = GameRecord.Deserialize(VersionOneRecord); + using var document = JsonDocument.Parse(GameRecord.Serialize(record)); + var root = document.RootElement; + + Assert.Multiple(() => + { + Assert.That(record.InitialFen, Is.EqualTo(ChessGame.StartingFen)); + Assert.That(record.Moves.Select(move => move.ToString()), Is.EqualTo(new[] { "e2e4", "e7e5" })); + Assert.That(record.DrawClaim, Is.Null); + Assert.That(root.EnumerateObject().Select(property => property.Name), + Is.EquivalentTo(new[] { "schemaVersion", "initialFen", "moves", "drawClaim" })); + Assert.That(root.GetProperty("schemaVersion").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("initialFen").GetString(), Is.EqualTo(ChessGame.StartingFen)); + Assert.That(root.GetProperty("moves").EnumerateArray().Select(move => move.GetString()), + Is.EqualTo(new[] { "e2e4", "e7e5" })); + Assert.That(root.GetProperty("drawClaim").ValueKind, Is.EqualTo(JsonValueKind.Null)); + }); + + var moves = (IList)record.Moves; + Assert.Throws(() => moves.Clear()); + } + + [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", "a7a8r")] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8b")] + [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a7a8n")] + [TestCase(RookEnding + "7 999999999999999999999", "a1a2")] + [TestCase(ChessGame.StartingFen, "f2f3 e7e5 g2g4 d8h4")] + [TestCase(ChessGame.StartingFen, RepetitionCycle + " " + RepetitionCycle)] + [TestCase(ChessGame.StartingFen, + RepetitionCycle + " " + RepetitionCycle + " " + RepetitionCycle + " " + RepetitionCycle)] + [TestCase(RookEnding + "149 80", "a1a2")] + public void JsonRoundTrip_PreservesGameHistoryOutcomeAndUndo(string fen, string moves) + { + var original = ChessGame.FromFen(fen); + Play(original, moves); + + var record = GameRecord.Deserialize(GameRecord.Serialize(original.ExportRecord())); + var restored = ChessGame.FromRecord(record); + + Assert.That(record.InitialFen, Is.EqualTo(fen)); + AssertEquivalent(original, restored); + while (original.UndoMove()) + { + Assert.That(restored.UndoMove(), Is.True); + AssertEquivalent(original, restored); + } + + Assert.That(restored.UndoMove(), Is.False); + } + + [TestCase(ChessGame.StartingFen, RepetitionCycle + " " + RepetitionCycle, + DrawClaim.ThreefoldRepetition, "threefoldRepetition", null)] + [TestCase(ChessGame.StartingFen, RepetitionCycle + " g1f3 g8f6 f3g1", + DrawClaim.ThreefoldRepetition, "threefoldRepetition", "f6g8")] + [TestCase(RookEnding + "100 50", "", DrawClaim.FiftyMoveRule, "fiftyMoveRule", null)] + [TestCase(RookEnding + "99 50", "", DrawClaim.FiftyMoveRule, "fiftyMoveRule", "a1a2")] + public void JsonRoundTrip_PreservesClaimWithoutPlayingIntendedMove( + string fen, string moves, DrawClaim reason, string reasonText, string? intendedMoveText) + { + var original = ChessGame.FromFen(fen); + Play(original, moves); + CoordinateMove? intendedMove = intendedMoveText is null ? null : CoordinateMove.Parse(intendedMoveText); + Assert.That(original.ClaimDraw(reason, intendedMove), Is.True); + + string json = GameRecord.Serialize(original.ExportRecord()); + using var document = JsonDocument.Parse(json); + var claimJson = document.RootElement.GetProperty("drawClaim"); + Assert.That(claimJson.GetProperty("reason").GetString(), Is.EqualTo(reasonText)); + Assert.That(claimJson.GetProperty("intendedMove").GetString(), Is.EqualTo(intendedMoveText)); + + var record = GameRecord.Deserialize(json); + Assert.That(record.DrawClaim, Is.EqualTo(new DrawClaimRecord(reason, intendedMove))); + AssertEquivalent(original, ChessGame.FromRecord(record)); + } + + [Test] + public void Deserialize_AllowsReorderedFieldsAndOmittedOptionalClaim() + { + string json = $$""" + { "moves": [], "initialFen": "{{ChessGame.StartingFen}}", "schemaVersion": 1 } + """; + + var record = GameRecord.Deserialize(json); + + Assert.That(record.Moves, Is.Empty); + Assert.That(record.DrawClaim, Is.Null); + } + + [Test] + public void Deserialize_AllowsClaimWithoutOptionalIntendedMove() + { + const string json = """ + { + "schemaVersion": 1, + "initialFen": "4k3/8/8/8/8/8/8/R3K3 w - - 100 50", + "moves": [], + "drawClaim": { "reason": "fiftyMoveRule" } + } + """; + + var record = GameRecord.Deserialize(json); + + Assert.That(record.DrawClaim, Is.EqualTo(new DrawClaimRecord(DrawClaim.FiftyMoveRule))); + } + + [TestCase("")] + [TestCase("{")] + [TestCase("null")] + [TestCase("[]")] + [TestCase("42")] + [TestCase("{}")] + [TestCase(VersionOneRecord + " {}")] + public void Deserialize_RejectsInvalidRoot(string json) + { + Assert.Throws(() => GameRecord.Deserialize(json)); + } + + [TestCase("schemaVersion")] + [TestCase("initialFen")] + [TestCase("moves")] + public void Deserialize_RejectsMissingRequiredFields(string field) + { + var document = JsonNode.Parse(VersionOneRecord)!.AsObject(); + document.Remove(field); + + Assert.Throws(() => GameRecord.Deserialize(document.ToJsonString())); + } + + [TestCase("schemaVersion", "null")] + [TestCase("schemaVersion", "0")] + [TestCase("schemaVersion", "-1")] + [TestCase("schemaVersion", "2")] + [TestCase("schemaVersion", "2147483648")] + [TestCase("schemaVersion", "1.5")] + [TestCase("schemaVersion", "\"1\"")] + [TestCase("initialFen", "null")] + [TestCase("initialFen", "42")] + [TestCase("initialFen", "\"not a FEN\"")] + [TestCase("moves", "null")] + [TestCase("moves", "\"e2e4\"")] + [TestCase("moves", "[null]")] + [TestCase("moves", "[42]")] + [TestCase("moves", "[{}]")] + [TestCase("moves", "[\"e2e2\"]")] + [TestCase("moves", "[\"e2e5\"]")] + [TestCase("drawClaim", "[]")] + [TestCase("drawClaim", "\"fiftyMoveRule\"")] + [TestCase("drawClaim", "{}")] + [TestCase("drawClaim", "{\"reason\":null}")] + [TestCase("drawClaim", "{\"reason\":2}")] + [TestCase("drawClaim", "{\"reason\":\"unknown\"}")] + [TestCase("drawClaim", "{\"reason\":\"none\"}")] + [TestCase("drawClaim", "{\"reason\":\"threefoldRepetition, fiftyMoveRule\"}")] + [TestCase("drawClaim", "{\"reason\":\"threefoldRepetition\"}")] + [TestCase("drawClaim", "{\"reason\":\"fiftyMoveRule\",\"intendedMove\":\"a1b2\"}")] + [TestCase("drawClaim", "{\"reason\":\"fiftyMoveRule\",\"intendedMove\":{}}")] + [TestCase("extra", "true")] + [TestCase("Moves", "[]")] + public void Deserialize_RejectsInvalidFields(string field, string value) + { + var document = JsonNode.Parse(VersionOneRecord)!; + document[field] = JsonNode.Parse(value); + + Assert.Throws(() => GameRecord.Deserialize(document.ToJsonString())); + } + + [TestCase("\"reason\":\"fiftyMoveRule\",\"extra\":true")] + [TestCase("\"reason\":\"fiftyMoveRule\",\"reason\":\"fiftyMoveRule\"")] + [TestCase("\"reason\":\"fiftyMoveRule\",\"intendedMove\":null,\"intendedMove\":null")] + public void Deserialize_RejectsUnknownOrDuplicateClaimFields(string claimFields) + { + string json = $$""" + { + "schemaVersion": 1, + "initialFen": "4k3/8/8/8/8/8/8/R3K3 w - - 100 50", + "moves": [], + "drawClaim": { {{claimFields}} } + } + """; + + Assert.Throws(() => GameRecord.Deserialize(json)); + } + + [TestCase("schemaVersion", "1")] + [TestCase("schemaVersion", "2")] + [TestCase("schema\\u0056ersion", "1")] + [TestCase("moves", "[]")] + [TestCase("drawClaim", "null")] + public void Deserialize_RejectsDuplicateRootFields(string field, string value) + { + string json = VersionOneRecord.Insert(1, $"\"{field}\":{value},"); + + Assert.Throws(() => GameRecord.Deserialize(json)); + } + + [TestCase("[\"e2e4\",\"d2d4\"]", 1)] + [TestCase("[\"f2f3\",\"e7e5\",\"g2g4\",\"d8h4\",\"a2a3\"]", 4)] + 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())); + + Assert.That(error!.Message, Does.Contain($"index {index}")); + Assert.That(error.InnerException, Is.TypeOf()); + } + + [Test] + public void Methods_RejectNullArguments() + { + Assert.Throws(() => GameRecord.Serialize(null!)); + Assert.Throws(() => GameRecord.Deserialize(null!)); + } + + private static void Play(ChessGame game, string moves) + { + foreach (string move in moves.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.Not.EqualTo(MoveResult.None), move); + } + } + + private static void AssertEquivalent(ChessGame expected, ChessGame actual) + { + Assert.Multiple(() => + { + Assert.That(actual.ToFen(), Is.EqualTo(expected.ToFen())); + Assert.That(actual.Outcome, Is.EqualTo(expected.Outcome)); + Assert.That(actual.History, Is.EqualTo(expected.History)); + Assert.That(actual.RepetitionCount, Is.EqualTo(expected.RepetitionCount)); + Assert.That(actual.AvailableDrawClaims, Is.EqualTo(expected.AvailableDrawClaims)); + Assert.That(actual.DrawIntendedMove, Is.EqualTo(expected.DrawIntendedMove)); + Assert.That(actual.GetLegalMoves(), Is.EquivalentTo(expected.GetLegalMoves())); + }); + } +} diff --git a/src/ChessRealms.Engine.Tests/Json/NotationConverterTests.cs b/src/ChessRealms.Engine.Tests/Json/NotationConverterTests.cs new file mode 100644 index 0000000..f54f9bf --- /dev/null +++ b/src/ChessRealms.Engine.Tests/Json/NotationConverterTests.cs @@ -0,0 +1,167 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; + +namespace ChessRealms.Engine.Tests.Json; + +internal class NotationConverterTests +{ + [TestCase("e2e4")] + [TestCase("a7a8q")] + [TestCase("a7a8r")] + [TestCase("a7a8b")] + [TestCase("a7a8n")] + public void Move_SerializesAsStringWithoutConfiguringOptions(string text) + { + var move = CoordinateMove.Parse(text); + string json = JsonSerializer.Serialize(move); + + Assert.That(json, Is.EqualTo($"\"{text}\"")); + Assert.That(JsonSerializer.Deserialize(json), Is.EqualTo(move)); + } + + [TestCase("a1")] + [TestCase("e4")] + [TestCase("h8")] + public void Square_SerializesAsStringWithoutConfiguringOptions(string text) + { + var square = Square.Parse(text); + string json = JsonSerializer.Serialize(square); + + Assert.That(json, Is.EqualTo($"\"{text}\"")); + Assert.That(JsonSerializer.Deserialize(json), Is.EqualTo(square)); + } + + [TestCase("null")] + [TestCase("42")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + [TestCase("\"\"")] + [TestCase("\"e2e\"")] + [TestCase("\"e2e2\"")] + [TestCase("\"e2e4qq\"")] + [TestCase("\"a7a8Q\"")] + [TestCase("\"\\u04352e4\"")] + [TestCase("\"e2e4\\u0000\"")] + [TestCase("\"e2\\uD800\"")] + [TestCase("\"e2\\uDC00\"")] + public void Move_RejectsInvalidJson(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [TestCase("null")] + [TestCase("42")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + [TestCase("\"\"")] + [TestCase("\"A1\"")] + [TestCase("\"a0\"")] + [TestCase("\"i1\"")] + [TestCase("\"a1 \"")] + [TestCase("\"\\u04301\"")] + [TestCase("\"\\uD800\"")] + [TestCase("\"\\uDC00\"")] + public void Square_RejectsInvalidJson(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Test] + public void Writers_RejectDefaultValues() + { + Assert.Throws(() => JsonSerializer.Serialize(default(CoordinateMove))); + Assert.Throws(() => JsonSerializer.Serialize(default(Square))); + } + + [Test] + public void Readers_RejectMalformedUtf8AsJsonErrors() + { + byte[] squareJson = [(byte)'"', (byte)'a', 0xFF, (byte)'"']; + byte[] moveJson = [(byte)'"', (byte)'e', (byte)'2', (byte)'e', 0xFF, (byte)'"']; + + Assert.Throws(() => JsonSerializer.Deserialize(squareJson)); + Assert.Throws(() => JsonSerializer.Deserialize(moveJson)); + } + + [TestCase("\"e2e4\"", "e2e4")] + [TestCase("\"\\u0065\\u0032\\u0065\\u0034\"", "e2e4")] + [TestCase("\"\\u0061\\u0037\\u0061\\u0038\\u006E\"", "a7a8n")] + public void Move_ReadsEscapedAndSegmentedStrings(string json, string text) + { + AssertReadsAtEverySplit(json, CoordinateMove.Parse(text)); + } + + [TestCase("\"h8\"", "h8")] + [TestCase("\"\\u0068\\u0038\"", "h8")] + public void Square_ReadsEscapedAndSegmentedStrings(string json, string text) + { + AssertReadsAtEverySplit(json, Square.Parse(text)); + } + + [TestCase(DrawClaim.None, "none")] + [TestCase(DrawClaim.ThreefoldRepetition, "threefoldRepetition")] + [TestCase(DrawClaim.FiftyMoveRule, "fiftyMoveRule")] + [TestCase(DrawClaim.ThreefoldRepetition | DrawClaim.FiftyMoveRule, "threefoldRepetition, fiftyMoveRule")] + public void DrawClaim_UsesStringsAndPreservesFlagsOutsideGameRecords(DrawClaim claim, string text) + { + string json = JsonSerializer.Serialize(claim); + + Assert.That(json, Is.EqualTo($"\"{text}\"")); + Assert.That(JsonSerializer.Deserialize(json), Is.EqualTo(claim)); + } + + [TestCase("1")] + [TestCase("\"1\"")] + [TestCase("\"unknown\"")] + [TestCase("null")] + public void DrawClaim_RejectsNumbersAndUnknownNames(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Test] + public void DrawClaimRecord_ComposesReasonAndIntendedMoveConverters() + { + var claim = new DrawClaimRecord(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1a2")); + string json = JsonSerializer.Serialize(claim); + using var document = JsonDocument.Parse(json); + + Assert.That(document.RootElement.GetProperty("Reason").GetString(), Is.EqualTo("fiftyMoveRule")); + Assert.That(document.RootElement.GetProperty("IntendedMove").GetString(), Is.EqualTo("a1a2")); + Assert.That(JsonSerializer.Deserialize(json), Is.EqualTo(claim)); + } + + private static void AssertReadsAtEverySplit(string json, T expected) + { + byte[] bytes = Encoding.UTF8.GetBytes(json); + Assert.That(JsonSerializer.Deserialize(bytes), Is.EqualTo(expected)); + + for (int split = 1; split < bytes.Length; ++split) + { + var first = new JsonSegment(bytes.AsMemory(0, split)); + var last = first.Append(bytes.AsMemory(split)); + var sequence = new ReadOnlySequence(first, 0, last, last.Memory.Length); + var reader = new Utf8JsonReader(sequence); + + Assert.That(JsonSerializer.Deserialize(ref reader), Is.EqualTo(expected), $"Split at byte {split}"); + } + } + + private sealed class JsonSegment : ReadOnlySequenceSegment + { + public JsonSegment(ReadOnlyMemory memory) + { + Memory = memory; + } + + public JsonSegment Append(ReadOnlyMemory memory) + { + var segment = new JsonSegment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = segment; + return segment; + } + } +} diff --git a/src/ChessRealms.Engine.Tests/Utf8NotationTests.cs b/src/ChessRealms.Engine.Tests/Utf8NotationTests.cs new file mode 100644 index 0000000..2767505 --- /dev/null +++ b/src/ChessRealms.Engine.Tests/Utf8NotationTests.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using System.Text; + +namespace ChessRealms.Engine.Tests; + +internal class Utf8NotationTests +{ + [Test] + public void Square_AllSquaresRoundTripThroughUtf8Interfaces() + { + for (char rank = '1'; rank <= '8'; ++rank) + { + for (char file = 'a'; file <= 'h'; ++file) + { + string text = $"{file}{rank}"; + var square = Square.Parse(text); + + AssertRoundTrip(square, text); + Assert.That(Square.TryParse(Encoding.UTF8.GetBytes(text), out var parsed), Is.True); + Assert.That(parsed, Is.EqualTo(square)); + } + } + } + + [TestCase("e2e4")] + [TestCase("e1g1")] + [TestCase("a7a8q")] + [TestCase("a7a8r")] + [TestCase("a7a8b")] + [TestCase("a7a8n")] + public void Move_RoundTripsThroughUtf8Interfaces(string text) + { + var move = CoordinateMove.Parse(text); + + AssertRoundTrip(move, text); + Assert.That(CoordinateMove.TryParse(Encoding.UTF8.GetBytes(text), out var parsed), Is.True); + Assert.That(parsed, Is.EqualTo(move)); + } + + [TestCase("")] + [TestCase("a")] + [TestCase("a1 ")] + [TestCase("A1")] + [TestCase("a0")] + [TestCase("i1")] + [TestCase("а1")] + [TestCase("é")] + public void Square_RejectsInvalidUtf8Notation(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + + Assert.That(Square.TryParse(bytes, null, out var square), Is.False); + Assert.That(square, Is.EqualTo(default(Square))); + Assert.Throws(() => Square.Parse(bytes)); + } + + [TestCase("")] + [TestCase("e2e")] + [TestCase("e2e4 ")] + [TestCase("e2e4qq")] + [TestCase("E2e4")] + [TestCase("e2e2")] + [TestCase("i2e4")] + [TestCase("e2e9")] + [TestCase("a7a8Q")] + [TestCase("a7a8k")] + [TestCase("a7a8p")] + [TestCase("е2e4")] + [TestCase("e2é")] + public void Move_RejectsInvalidUtf8Notation(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + + Assert.That(CoordinateMove.TryParse(bytes, null, out var move), Is.False); + Assert.That(move, Is.EqualTo(default(CoordinateMove))); + Assert.Throws(() => CoordinateMove.Parse(bytes)); + } + + [Test] + public void Parsers_RejectMalformedUtf8() + { + byte[] square = [(byte)'a', 0xFF]; + byte[] move = [(byte)'e', (byte)'2', (byte)'e', 0xFF]; + + Assert.That(Square.TryParse(square, out _), Is.False); + Assert.That(CoordinateMove.TryParse(move, out _), Is.False); + Assert.Throws(() => Square.Parse(square)); + Assert.Throws(() => CoordinateMove.Parse(move)); + } + + [Test] + public void Formatters_RejectInvalidDefaultsWithoutWriting() + { + AssertFormatFailure(default(Square), 2); + AssertFormatFailure(default(CoordinateMove), 5); + } + + [Test] + public void Formatters_RejectShortBuffersWithoutWriting() + { + AssertFormatFailure(Square.Parse("e4"), 0); + AssertFormatFailure(Square.Parse("e4"), 1); + AssertFormatFailure(CoordinateMove.Parse("e2e4"), 3); + AssertFormatFailure(CoordinateMove.Parse("a7a8q"), 4); + } + + [TestCase("X")] + [TestCase("GG")] + public void Formatters_RejectUnsupportedFormats(string format) + { + byte[] destination = new byte[5]; + + Assert.Throws(() => Square.Parse("e4").TryFormat(destination, out _, format)); + Assert.Throws(() => CoordinateMove.Parse("e2e4").TryFormat(destination, out _, format)); + } + + private static void AssertRoundTrip(T value, string text) + where T : IUtf8SpanFormattable, IUtf8SpanParsable + { + byte[] expected = Encoding.UTF8.GetBytes(text); + var provider = CultureInfo.GetCultureInfo("tr-TR"); + + Assert.That(T.Parse(expected, provider), Is.EqualTo(value)); + Assert.That(T.TryParse(expected, provider, out var parsed), Is.True); + Assert.That(parsed, Is.EqualTo(value)); + + foreach (string format in new[] { "", "G", "g" }) + { + byte[] buffer = Enumerable.Repeat((byte)0xFF, expected.Length + 2).ToArray(); + Assert.That(value.TryFormat(buffer.AsSpan(1, expected.Length), out int written, format, provider), Is.True); + Assert.That(written, Is.EqualTo(expected.Length)); + Assert.That(buffer[1..^1], Is.EqualTo(expected)); + Assert.That(buffer[0], Is.EqualTo(0xFF)); + Assert.That(buffer[^1], Is.EqualTo(0xFF)); + } + } + + private static void AssertFormatFailure(T value, int capacity) where T : IUtf8SpanFormattable + { + byte[] buffer = Enumerable.Repeat((byte)0xFF, capacity).ToArray(); + + Assert.That(value.TryFormat(buffer, out int written, default, null), Is.False); + Assert.That(written, Is.Zero); + Assert.That(buffer, Is.All.EqualTo(0xFF)); + } +} diff --git a/src/ChessRealms.Engine/CoordinateMove.cs b/src/ChessRealms.Engine/CoordinateMove.cs index 281c639..bd0ad34 100644 --- a/src/ChessRealms.Engine/CoordinateMove.cs +++ b/src/ChessRealms.Engine/CoordinateMove.cs @@ -1,9 +1,12 @@ using ChessRealms.Engine.Core.Math; +using ChessRealms.Engine.Json; +using System.Text.Json.Serialization; namespace ChessRealms.Engine; /// A move written as source and target squares, with an explicit promotion when required. -public readonly record struct CoordinateMove +[JsonConverter(typeof(CoordinateMoveConverter))] +public readonly record struct CoordinateMove : IUtf8SpanFormattable, IUtf8SpanParsable { /// Creates a coordinate move. /// A square or promotion value is invalid. @@ -102,6 +105,73 @@ public static bool TryParse(ReadOnlySpan text, out CoordinateMove move) return true; } + /// Parses lowercase UTF-8 coordinate notation. The provider is ignored. + public static CoordinateMove Parse(ReadOnlySpan utf8Text, IFormatProvider? provider = null) + { + return TryParse(utf8Text, provider, out var move) + ? move + : throw new FormatException("Expected e2e4 or a7a8q/r/b/n."); + } + + /// Tries to parse lowercase UTF-8 coordinate notation. + public static bool TryParse(ReadOnlySpan utf8Text, out CoordinateMove move) + => TryParse(utf8Text, null, out move); + + /// Tries to parse lowercase UTF-8 coordinate notation. The provider is ignored. + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, out CoordinateMove move) + { + move = default; + if (utf8Text.Length is not (4 or 5)) + { + return false; + } + + // Coordinate notation is ASCII; the character parser rejects every non-ASCII byte. + Span text = stackalloc char[utf8Text.Length]; + for (int i = 0; i < utf8Text.Length; ++i) + { + text[i] = (char)utf8Text[i]; + } + + return TryParse(text, out move); + } + + /// Writes lowercase coordinate notation as UTF-8 without allocating a string. + /// Accepts an empty format or G/g and ignores the provider. Returns false without writing + /// for an invalid move or insufficient space. Other formats throw . + public bool TryFormat(Span utf8Destination, out int bytesWritten, + ReadOnlySpan format = default, IFormatProvider? provider = null) + { + bytesWritten = 0; + if (!format.IsEmpty && !format.SequenceEqual("G") && !format.SequenceEqual("g")) + { + throw new FormatException("Only the general coordinate move format is supported."); + } + + int length = Promotion == PieceValue.None ? 4 : 5; + if (!IsValid || utf8Destination.Length < length) + { + return false; + } + + Source.TryFormat(utf8Destination[..2], out _); + Target.TryFormat(utf8Destination.Slice(2, 2), out _); + if (Promotion != PieceValue.None) + { + utf8Destination[4] = Promotion switch + { + PieceValue.Queen => (byte)'q', + PieceValue.Rook => (byte)'r', + PieceValue.Bishop => (byte)'b', + PieceValue.Knight => (byte)'n', + _ => throw new InvalidOperationException("Invalid promotion piece.") + }; + } + + bytesWritten = length; + return true; + } + /// public override string ToString() { diff --git a/src/ChessRealms.Engine/GameOutcome.cs b/src/ChessRealms.Engine/GameOutcome.cs index 7efed8b..c96c6ee 100644 --- a/src/ChessRealms.Engine/GameOutcome.cs +++ b/src/ChessRealms.Engine/GameOutcome.cs @@ -1,3 +1,6 @@ +using ChessRealms.Engine.Json; +using System.Text.Json.Serialization; + namespace ChessRealms.Engine; public enum GameState @@ -16,6 +19,7 @@ public enum GameResult } [Flags] +[JsonConverter(typeof(DrawClaimConverter))] public enum DrawClaim { None = 0, diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index 0221e17..c383666 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -1,4 +1,5 @@ using ChessRealms.Engine.Core.Types; +using ChessRealms.Engine.Json; using ChessRealms.Engine.Parsing; namespace ChessRealms.Engine; @@ -18,6 +19,16 @@ public sealed class GameRecord /// The successful draw claim, or null if no claim ended the game. public DrawClaimRecord? DrawClaim { get; } + /// Serializes a game record to versioned JSON with coordinate moves written as strings. + /// The record is null. + public static string Serialize(GameRecord record) => GameRecordJsonSerializer.Serialize(record); + + /// Deserializes a game record and validates it by replaying its moves and draw claim. + /// The JSON string is null. + /// The JSON is invalid, its schema is unsupported, + /// or the game cannot be restored. + public static GameRecord Deserialize(string json) => GameRecordJsonSerializer.Deserialize(json); + internal GameRecord(string fen, IReadOnlyList moves, DrawClaimRecord? drawClaim) { ArgumentNullException.ThrowIfNull(fen); diff --git a/src/ChessRealms.Engine/Json/CoordinateMoveConverter.cs b/src/ChessRealms.Engine/Json/CoordinateMoveConverter.cs new file mode 100644 index 0000000..5249acb --- /dev/null +++ b/src/ChessRealms.Engine/Json/CoordinateMoveConverter.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChessRealms.Engine.Json; + +internal sealed class CoordinateMoveConverter : JsonConverter +{ + private const int MaxCoordinateMoveLength = 5; + + public override CoordinateMove Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected a coordinate move string."); + } + + Span buffer = stackalloc byte[MaxCoordinateMoveLength]; + + int length; + try + { + length = reader.CopyString(buffer); + } + catch (ArgumentException ex) + { + throw new JsonException("Invalid coordinate move length.", ex); + } + + if (!CoordinateMove.TryParse(buffer[..length], out var move)) + { + throw new JsonException("Invalid coordinate move."); + } + + return move; + } + + public override void Write(Utf8JsonWriter writer, CoordinateMove value, JsonSerializerOptions options) + { + Span buffer = stackalloc byte[MaxCoordinateMoveLength]; + if (!value.TryFormat(buffer, out int length)) + { + throw new JsonException("Cannot serialize an invalid coordinate move."); + } + + writer.WriteStringValue(buffer[..length]); + } +} diff --git a/src/ChessRealms.Engine/Json/DrawClaimConverter.cs b/src/ChessRealms.Engine/Json/DrawClaimConverter.cs new file mode 100644 index 0000000..0679ca9 --- /dev/null +++ b/src/ChessRealms.Engine/Json/DrawClaimConverter.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChessRealms.Engine.Json; + +internal sealed class DrawClaimConverter : JsonStringEnumConverter +{ + public DrawClaimConverter() : base(JsonNamingPolicy.CamelCase, allowIntegerValues: false) + { + } +} diff --git a/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs b/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs new file mode 100644 index 0000000..ff4d401 --- /dev/null +++ b/src/ChessRealms.Engine/Json/GameRecordJsonSerializer.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChessRealms.Engine.Json; + +internal static class GameRecordJsonSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + AllowDuplicateProperties = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true + }; + + internal static string Serialize(GameRecord record) + { + ArgumentNullException.ThrowIfNull(record); + + var document = new GameRecordDocument + { + SchemaVersion = GameRecord.SchemaVersion, + InitialFen = record.InitialFen, + Moves = record.Moves, + DrawClaim = record.DrawClaim + }; + + return JsonSerializer.Serialize(document, Options); + } + + internal static GameRecord Deserialize(string json) + { + ArgumentNullException.ThrowIfNull(json); + + var document = JsonSerializer.Deserialize(json, Options); + + if (document.SchemaVersion != GameRecord.SchemaVersion) + { + throw new JsonException($"Unsupported game record schema version: {document.SchemaVersion}."); + } + + var record = new GameRecord(document.InitialFen, document.Moves, document.DrawClaim); + try + { + // Replay is the authority for move legality and draw claims, including intended moves. + GameRecord.RestoreGame(record); + } + catch (FormatException ex) + { + throw new JsonException(ex.Message, ex); + } + + return record; + } + + private readonly record struct GameRecordDocument + { + public required int SchemaVersion { get; init; } + public required string InitialFen { get; init; } + public required IReadOnlyList Moves { get; init; } + public DrawClaimRecord? DrawClaim { get; init; } + } +} diff --git a/src/ChessRealms.Engine/Json/SquareConverter.cs b/src/ChessRealms.Engine/Json/SquareConverter.cs new file mode 100644 index 0000000..0dc2c4d --- /dev/null +++ b/src/ChessRealms.Engine/Json/SquareConverter.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChessRealms.Engine.Json; + +internal sealed class SquareConverter : JsonConverter +{ + private const int SquareLength = 2; + + public override Square Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected a square string."); + } + + Span buffer = stackalloc byte[SquareLength]; + int length; + try + { + length = reader.CopyString(buffer); + } + catch (ArgumentException ex) + { + throw new JsonException("Invalid square length.", ex); + } + + if (!Square.TryParse(buffer[..length], out var square)) + { + throw new JsonException("Invalid square."); + } + + return square; + } + + public override void Write(Utf8JsonWriter writer, Square value, JsonSerializerOptions options) + { + Span buffer = stackalloc byte[SquareLength]; + if (!value.TryFormat(buffer, out int length)) + { + throw new JsonException("Cannot serialize an invalid square."); + } + + writer.WriteStringValue(buffer[..length]); + } +} diff --git a/src/ChessRealms.Engine/Square.cs b/src/ChessRealms.Engine/Square.cs index cbdcea1..840dd1c 100644 --- a/src/ChessRealms.Engine/Square.cs +++ b/src/ChessRealms.Engine/Square.cs @@ -1,7 +1,11 @@ +using ChessRealms.Engine.Json; +using System.Text.Json.Serialization; + namespace ChessRealms.Engine; /// A square on a standard chess board. -public readonly record struct Square +[JsonConverter(typeof(SquareConverter))] +public readonly record struct Square : IUtf8SpanFormattable, IUtf8SpanParsable { private readonly byte encodedIndex; @@ -37,6 +41,55 @@ public static bool TryParse(ReadOnlySpan text, out Square square) return true; } + /// Parses a lowercase UTF-8 square name. The provider is ignored. + public static Square Parse(ReadOnlySpan utf8Text, IFormatProvider? provider = null) + { + return TryParse(utf8Text, provider, out var square) + ? square + : throw new FormatException("Expected a square a1 through h8."); + } + + /// Tries to parse a lowercase UTF-8 square name. + public static bool TryParse(ReadOnlySpan utf8Text, out Square square) + => TryParse(utf8Text, null, out square); + + /// Tries to parse a lowercase UTF-8 square name. The provider is ignored. + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, out Square square) + { + square = default; + if (utf8Text.Length != 2 || utf8Text[0] is < (byte)'a' or > (byte)'h' + || utf8Text[1] is < (byte)'1' or > (byte)'8') + { + return false; + } + + square = new((utf8Text[1] - '1') * 8 + utf8Text[0] - 'a'); + return true; + } + + /// Writes a lowercase square name as UTF-8 without allocating a string. + /// Accepts an empty format or G/g and ignores the provider. Returns false without writing + /// for an invalid square or insufficient space. Other formats throw . + public bool TryFormat(Span utf8Destination, out int bytesWritten, + ReadOnlySpan format = default, IFormatProvider? provider = null) + { + bytesWritten = 0; + if (!format.IsEmpty && !format.SequenceEqual("G") && !format.SequenceEqual("g")) + { + throw new FormatException("Only the general square format is supported."); + } + + if (!IsValid || utf8Destination.Length < 2) + { + return false; + } + + utf8Destination[0] = (byte)('a' + Index % 8); + utf8Destination[1] = (byte)('1' + Index / 8); + bytesWritten = 2; + return true; + } + /// public override string ToString() => IsValid ? Core.Math.SquareOps.ToAbbreviature(Index) : string.Empty; } From d4c98a9f1133aedb7828c83cf32796cec02c3c85 Mon Sep 17 00:00:00 2001 From: Hash Date: Mon, 19 Oct 2026 02:34:25 +0300 Subject: [PATCH 6/8] perf(engine): reduce game state allocations --- ChessRealms.Engine.slnx | 1 + README.md | 4 + docs/performance.md | 76 ++++++++++++++++ .../GameApiBenchmarks.cs | 64 +++++++++++++ src/ChessRealms.Engine.Benchmark/Program.cs | 11 ++- .../CompleteGameRulesTests.cs | 22 +++++ .../Perft/PerftTests.cs | 25 ++++++ src/ChessRealms.Engine/ChessGame.cs | 90 +++++++++++++------ src/ChessRealms.Engine/GameRecord.cs | 11 ++- src/ChessRealms.Engine/Parsing/FenStrings.cs | 19 +++- 10 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 docs/performance.md create mode 100644 src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 3087ea8..569d885 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index 4bd2388..87c9a50 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ The `Deep` test category contains slower perft cases and is opt-in. See the [contribution guide](https://github.com/ChessRealms/Engine/blob/main/CONTRIBUTING.md) for contributor checks. +See the +[performance measurements](https://github.com/ChessRealms/Engine/blob/main/docs/performance.md) +for game API allocation benchmarks, reproduction commands and ownership guarantees. + ## Public API ```csharp diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..59c63dd --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,76 @@ +# Measuring game API allocations + +`GameApiBenchmarks` measures public operations with an empty game and a game +after 20 plies. Setup, notation parsing and input JSON preparation happen +outside the measured operations. `MakeAndUndoMove` plays one legal move and +undoes it on the same game; warmup has already grown the internal collections. +Restoration and deserialization create a new game each time, including all +validation and replay. Serialization measures an already exported record. + +Run from the repository root: + +```sh +dotnet build src/ChessRealms.Engine.Benchmark -c Release +dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*GameApiBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/game-api --exporters JSON +``` + +The benchmark project also supports the usual BenchmarkDotNet arguments. For a +longer run with isolated benchmark processes, omit `--inProcess` and the short +iteration settings. Running without arguments preserves the existing perft +benchmark. Benchmark artifacts are ignored by Git. + +## Allocation changes measured on 2026-10-19 + +The baseline engine was commit `1ea5cc4`, with the same benchmark harness added +before changing the implementation. Both runs used Windows 11 x64, SDK +10.0.402, .NET 10.0.13 and concurrent workstation GC on the same machine. +The command above was used with separate `game-api-before` and `game-api-after` +artifact directories. + +Allocated managed bytes per operation: + +| Operation | Plies | Before | After | +| --- | ---: | ---: | ---: | +| Get legal moves | 0 | 648 | 208 | +| Read FEN | 0 | 480 | 0 | +| Make and undo a move | 0 | 2776 | 1048 | +| Export record | 0 | 96 | 40 | +| Restore record | 0 | 2728 | 2224 | +| Serialize record | 0 | 664 | 664 | +| Deserialize record | 0 | 3520 | 3016 | +| Get legal moves | 20 | 1056 | 336 | +| Read FEN | 20 | 704 | 0 | +| Make and undo a move | 20 | 3952 | 1416 | +| Export record | 20 | 1176 | 248 | +| Restore record | 20 | 75918 | 40996 | +| Serialize record | 20 | 1224 | 1224 | +| Deserialize record | 20 | 77494 | 42572 | + +At 20 plies the mean restoration time changed from 145.4 to 100.1 microseconds, +and deserialization from 149.3 to 104.8 microseconds. These are short local +measurements, not production latency guarantees. BenchmarkDotNet could not set +the power plan and reported some iterations below its recommended 100 ms +minimum. Treat timing differences as indicative; FEN access is now close to +measurement overhead, so no timing ratio is claimed for it. + +## Implementation and ownership + +- Internal legal-move generation filters moves in a bounded, local span. The + public `GetLegalMoves()` still returns an independently owned, read-only + snapshot, including every promotion choice. +- Record export copies coordinate moves directly from the owned history once. + It avoids the public history snapshot and a second move-array copy. The + constructor accepting caller-owned moves still copies them defensively. +- `ToFen()` reuses the initial FEN or the last history entry's `FenAfter`. + Moves already store that string; undo and branching select the correct + history entry. No additional mutable cache is introduced. +- Repetition keys format only the four position fields, excluding counters. + En passant is still included only when a legal en passant move exists. +- `HasLegalMoves` uses the already evaluated outcome: a position without legal + moves has already finished. Claimed and automatic draws also prohibit play. + +Public history snapshots, game-record immutability, JSON schema version one and +validated restoration keep their existing contracts. JSON serialization itself +is unchanged in this pass. Its allocation count includes the result string and +serializer overhead. Deserialization still validates by replaying; calling +`ChessGame.FromRecord` afterwards performs a second replay. diff --git a/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs new file mode 100644 index 0000000..04eadb2 --- /dev/null +++ b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs @@ -0,0 +1,64 @@ +using BenchmarkDotNet.Attributes; + +namespace ChessRealms.Engine.Benchmark; + +[MemoryDiagnoser] +public class GameApiBenchmarks +{ + private ChessGame game = null!; + private GameRecord record = null!; + private string json = null!; + private CoordinateMove nextMove; + + [Params(0, 20)] + public int MoveCount { get; set; } + + [GlobalSetup] + public void Setup() + { + const string line = "e2e4 e7e5 g1f3 b8c6 f1b5 a7a6 b5a4 g8f6 e1g1 f8e7 " + + "f1e1 b7b5 a4b3 d7d6 c2c3 e8g8 h2h3 c6b8 d2d4 b8d7"; + game = new ChessGame(); + foreach (string text in line.Split(' ').Take(MoveCount)) + { + if (game.MakeMove(CoordinateMove.Parse(text)) == MoveResult.None) + { + throw new InvalidOperationException($"Invalid benchmark move: {text}."); + } + } + + nextMove = CoordinateMove.Parse(MoveCount == 0 ? "e2e4" : "b1d2"); + record = game.ExportRecord(); + json = GameRecord.Serialize(record); + } + + [Benchmark] + public IReadOnlyList LegalMoves() => game.GetLegalMoves(); + + [Benchmark] + public string Fen() => game.ToFen(); + + [Benchmark] + public MoveResult MakeAndUndoMove() + { + var result = game.MakeMove(nextMove); + if (result == MoveResult.None || !game.UndoMove()) + { + throw new InvalidOperationException("The benchmark must return to its starting position."); + } + + return result; + } + + [Benchmark] + public GameRecord ExportRecord() => game.ExportRecord(); + + [Benchmark] + public ChessGame RestoreRecord() => ChessGame.FromRecord(record); + + [Benchmark] + public string Serialize() => GameRecord.Serialize(record); + + [Benchmark] + public GameRecord Deserialize() => GameRecord.Deserialize(json); +} diff --git a/src/ChessRealms.Engine.Benchmark/Program.cs b/src/ChessRealms.Engine.Benchmark/Program.cs index 48fef9f..577279a 100644 --- a/src/ChessRealms.Engine.Benchmark/Program.cs +++ b/src/ChessRealms.Engine.Benchmark/Program.cs @@ -4,7 +4,7 @@ using BenchmarkDotNet.Toolchains.InProcess.Emit; using ChessRealms.Engine.Benchmark; -Console.WriteLine("Hello, Benckmarks!"); +Console.WriteLine("ChessRealms benchmarks"); Console.WriteLine(); // Preserve the original full run; allow CLI job selection for smoke checks. @@ -15,4 +15,11 @@ .WithToolchain(InProcessEmitToolchain.DontLogOutput)) : DefaultConfig.Instance; -BenchmarkRunner.Run(config, args: args); +if (args.Length == 0) +{ + BenchmarkRunner.Run(config); +} +else +{ + BenchmarkSwitcher.FromAssembly(typeof(PerftBenchmarks).Assembly).Run(args, config); +} diff --git a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs index a77ca34..a32af81 100644 --- a/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs +++ b/src/ChessRealms.Engine.Tests/CompleteGameRulesTests.cs @@ -149,6 +149,7 @@ public void LoadedTerminalPosition_IsClassifiedAndRejectsMoves(string fen, Finis Assert.That(game.IsFinished, Is.True); Assert.That(game.Outcome.Reason, Is.EqualTo(reason)); Assert.That(game.Outcome.Winner, Is.EqualTo(winner)); + Assert.That(game.HasLegalMoves, Is.False); Assert.That(game.GetLegalMoves(), Is.Empty); string before = State(game); Assert.That(game.MakeMove(CoordinateMove.Parse("h8h7")), Is.EqualTo(MoveResult.None)); @@ -412,10 +413,29 @@ public void CaptureIntoDeadPosition_AndUndo() string before = State(game); Play(game, "d2e3"); Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.DeadPosition)); + Assert.That(game.HasLegalMoves, Is.False); game.UndoMove(); + Assert.That(game.HasLegalMoves, Is.True); Assert.That(State(game), Is.EqualTo(before)); } + [Test] + public void LegalMoveAvailability_TracksDrawClaimsCloningAndUndo() + { + var game = Game("4k3/8/8/8/8/8/8/R3K3 w - - 99 50"); + Play(game, "a1a2"); + Assert.That(game.HasLegalMoves, Is.True); + Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule), Is.True); + var clone = game.Clone(); + + Assert.That(game.HasLegalMoves, Is.False); + Assert.That(clone.HasLegalMoves, Is.False); + Assert.That(game.UndoMove(), Is.True); + Assert.That(game.HasLegalMoves, Is.True); + Assert.That(game.GetLegalMoves(), Is.Not.Empty); + Assert.That(clone.HasLegalMoves, Is.False); + } + [TestCase(17)] [TestCase(12345)] [TestCase(987654)] @@ -438,6 +458,7 @@ public void DeterministicPlayouts_AllLegalMovesApplyToClones_AndUndoExactly(int 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(imported, Is.EqualTo(copy.Position)); Assert.That(FenStrings.Format(imported), Is.EqualTo(copy.ToFen())); Assert.That(copy.UndoMove(), Is.True); Assert.That(copy.Position, Is.EqualTo(positionBefore)); @@ -451,6 +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(State(game), Is.EqualTo(states[i])); } Assert.That(game.UndoMove(), Is.False); diff --git a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs index 688c0e4..48e1794 100644 --- a/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs +++ b/src/ChessRealms.Engine.Tests/Perft/PerftTests.cs @@ -40,6 +40,31 @@ private static IEnumerable Cases(bool deep) [TestCaseSource(nameof(FastCases)), Category("Perft")] public void Fast(string fen, int depth, ulong expected) => AssertNodes(fen, depth, expected); + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [Category("Perft")] + public void PublicGameApi_MatchesKnownMoveCountsThroughDepthTwo(int index) + { + var game = ChessGame.FromFen(Positions[index].Fen); + var moves = game.GetLegalMoves(); + Assert.That((ulong)moves.Count, Is.EqualTo(Positions[index].Nodes[0])); + + ulong nodes = 0; + foreach (var move in moves) + { + var child = game.Clone(); + Assert.That(child.MakeMove(move), Is.Not.EqualTo(MoveResult.None)); + nodes += (ulong)child.GetLegalMoves().Count; + } + + Assert.That(nodes, Is.EqualTo(Positions[index].Nodes[1])); + } + [TestCaseSource(nameof(DeepCases)), Category("Perft"), Category("Deep")] [Explicit("Expensive perft: select with --filter TestCategory=Deep")] public void Deep(string fen, int depth, ulong expected) => AssertNodes(fen, depth, expected); diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs index 11c95a3..e033126 100644 --- a/src/ChessRealms.Engine/ChessGame.cs +++ b/src/ChessRealms.Engine/ChessGame.cs @@ -66,11 +66,12 @@ internal ChessGame(Position position) this.position = position; initialFen = FenStrings.Format(position); - var legalMoves = GenerateLegalMoves(position); - string key = CreateRepetitionKey(position, legalMoves); + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int legalMoveCount = WriteLegalMoves(position, legalMoves); + string key = CreateRepetitionKey(position, legalMoves[..legalMoveCount]); keys.Add(key); repetitions.Add(key, 1); - Outcome = EvaluateOutcome(position, legalMoves.Count, 1); + Outcome = EvaluateOutcome(position, legalMoveCount, 1); } private ChessGame(ChessGame original) @@ -89,11 +90,27 @@ private ChessGame(ChessGame original) } public ChessGame Clone() => new(this); - public string ToFen() => FenStrings.FormatUnchecked(position); + public string ToFen() => history.Count == 0 ? initialFen : history[^1].FenAfter; /// Exports an independent snapshot of the starting position, current move line and draw claim. public GameRecord ExportRecord() => GameRecord.CreateFromGame(this); + internal CoordinateMove[] CopyRecordedMoves() + { + if (history.Count == 0) + { + return []; + } + + var moves = new CoordinateMove[history.Count]; + for (int i = 0; i < history.Count; ++i) + { + moves[i] = history[i].Move; + } + + return moves; + } + /// Restores an independent game by replaying the record's moves and applying its draw claim. /// The record is null. /// The initial FEN, a recorded move or the draw claim is invalid. @@ -148,10 +165,19 @@ public IReadOnlyList GetLegalMoves() return Array.Empty(); } - return Array.AsReadOnly(GenerateLegalMoves(position).Select(ToPublicMove).ToArray()); + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int legalMoveCount = WriteLegalMoves(position, legalMoves); + var moves = new CoordinateMove[legalMoveCount]; + for (int i = 0; i < legalMoveCount; ++i) + { + moves[i] = ToPublicMove(legalMoves[i]); + } + + return Array.AsReadOnly(moves); } - public bool HasLegalMoves => !IsFinished && GenerateLegalMoves(position).Count != 0; + // Outcome evaluation already finishes games with no legal moves. + public bool HasLegalMoves => !IsFinished; public MoveResult MakeMove(CoordinateMove move) { @@ -164,10 +190,11 @@ public MoveResult MakeMove(CoordinateMove move) MoveDriver.MakeMove(ref nextPosition, encodedMove); nextPosition.SwitchColor(); - var replies = GenerateLegalMoves(nextPosition); - string key = CreateRepetitionKey(nextPosition, replies); + Span replies = stackalloc int[MoveGen.MaxMoves]; + int replyCount = WriteLegalMoves(nextPosition, replies); + string key = CreateRepetitionKey(nextPosition, replies[..replyCount]); int repetitionCount = repetitions.GetValueOrDefault(key) + 1; - var outcome = EvaluateOutcome(nextPosition, replies.Count, repetitionCount); + var outcome = EvaluateOutcome(nextPosition, replyCount, repetitionCount); MoveResult result = MoveResult.Move; if (BinaryMoveOps.DecodeCapture(encodedMove) != 0) @@ -219,7 +246,9 @@ public bool UndoMove() position = undo[^1]; undo.RemoveAt(undo.Count - 1); history.RemoveAt(history.Count - 1); - Outcome = EvaluateOutcome(position, GenerateLegalMoves(position).Count, RepetitionCount); + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int legalMoveCount = WriteLegalMoves(position, legalMoves); + Outcome = EvaluateOutcome(position, legalMoveCount, RepetitionCount); DrawIntendedMove = null; return true; @@ -237,8 +266,9 @@ public DrawClaim GetAvailableDrawClaims(CoordinateMove intendedMove) MoveDriver.MakeMove(ref nextPosition, encodedMove); nextPosition.SwitchColor(); - var legalMoves = GenerateLegalMoves(nextPosition); - string key = CreateRepetitionKey(nextPosition, legalMoves); + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int legalMoveCount = WriteLegalMoves(nextPosition, legalMoves); + string key = CreateRepetitionKey(nextPosition, legalMoves[..legalMoveCount]); int repetitionCount = repetitions.GetValueOrDefault(key) + 1; return CalculateDrawClaims(nextPosition, repetitionCount); } @@ -277,7 +307,9 @@ private bool TryFindMove(CoordinateMove move, out int encodedMove) return false; } - foreach (int candidate in GenerateLegalMoves(position)) + Span legalMoves = stackalloc int[MoveGen.MaxMoves]; + int legalMoveCount = WriteLegalMoves(position, legalMoves); + foreach (int candidate in legalMoves[..legalMoveCount]) { if (ToPublicMove(candidate) != move) { @@ -296,36 +328,42 @@ private bool TryFindMove(CoordinateMove move, out int encodedMove) new Square(BinaryMoveOps.DecodeTrg(encodedMove)), BinaryMoveOps.DecodePromotion(encodedMove).ToPublicPromotion()); - private static List GenerateLegalMoves(Position position) + private static int WriteLegalMoves(Position position, Span moves) { - Span buffer = stackalloc int[MoveGen.MaxMoves]; - int written = MoveGen.WriteMoves(ref position, position.color, buffer); - List legalMoves = []; + 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, buffer[i], updateCounters: false); + MoveDriver.MakeMove(ref nextPosition, moves[i], updateCounters: false); if (!nextPosition.IsKingChecked(position.color)) { - legalMoves.Add(buffer[i]); + moves[legalMoveCount++] = moves[i]; } } - return legalMoves; + return legalMoveCount; } - private static string CreateRepetitionKey(Position position, List legalMoves) + private static string CreateRepetitionKey(Position position, ReadOnlySpan legalMoves) { - if (!legalMoves.Any(move => BinaryMoveOps.DecodeEnpassant(move) != 0)) + bool hasLegalEnPassant = false; + foreach (int move in legalMoves) + { + if (BinaryMoveOps.DecodeEnpassant(move) != 0) + { + hasLegalEnPassant = true; + break; + } + } + + if (!hasLegalEnPassant) { position.enpassant = Squares.Empty; } - string fen = FenStrings.FormatUnchecked(position); - int fullmoveSeparator = fen.LastIndexOf(' '); - int halfmoveSeparator = fen.LastIndexOf(' ', fullmoveSeparator - 1); - return fen[..halfmoveSeparator]; + return FenStrings.FormatPositionFields(position); } private static DrawClaim CalculateDrawClaims(Position position, int repetitionCount) => diff --git a/src/ChessRealms.Engine/GameRecord.cs b/src/ChessRealms.Engine/GameRecord.cs index c383666..04dafa9 100644 --- a/src/ChessRealms.Engine/GameRecord.cs +++ b/src/ChessRealms.Engine/GameRecord.cs @@ -39,12 +39,17 @@ internal GameRecord(string fen, IReadOnlyList moves, DrawClaimRe DrawClaim = drawClaim; } + private GameRecord(ChessGame game, DrawClaimRecord? drawClaim) + { + InitialFen = game.InitialFen; + Moves = Array.AsReadOnly(game.CopyRecordedMoves()); + DrawClaim = drawClaim; + } + internal static GameRecord CreateFromGame(ChessGame chessGame) { ArgumentNullException.ThrowIfNull(chessGame); - var fen = chessGame.InitialFen; - var moves = chessGame.History.Select(entry => entry.Move).ToArray(); DrawClaimRecord? drawClaim = chessGame.Outcome.Reason switch { FinishReason.ThreefoldRepetition => new(ChessRealms.Engine.DrawClaim.ThreefoldRepetition, @@ -54,7 +59,7 @@ internal static GameRecord CreateFromGame(ChessGame chessGame) _ => null }; - return new GameRecord(fen, moves, drawClaim); + return new GameRecord(chessGame, drawClaim); } internal static ChessGame RestoreGame(GameRecord gameRecord) diff --git a/src/ChessRealms.Engine/Parsing/FenStrings.cs b/src/ChessRealms.Engine/Parsing/FenStrings.cs index 810f344..356151d 100644 --- a/src/ChessRealms.Engine/Parsing/FenStrings.cs +++ b/src/ChessRealms.Engine/Parsing/FenStrings.cs @@ -187,6 +187,22 @@ internal static string Format(Position position) internal static string FormatUnchecked(Position position) { var result = new StringBuilder(); + AppendPositionFields(result, position); + result.Append(' ').Append(position.halfMoveClock.ToString(CultureInfo.InvariantCulture)); + result.Append(' ').Append(position.fullMoveCount.ToString(CultureInfo.InvariantCulture)); + return result.ToString(); + } + + // 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; @@ -245,8 +261,5 @@ internal static string FormatUnchecked(Position position) ? "-" : SquareOps.ToAbbreviature(position.enpassant); result.Append(' ').Append(enPassant); - result.Append(' ').Append(position.halfMoveClock.ToString(CultureInfo.InvariantCulture)); - result.Append(' ').Append(position.fullMoveCount.ToString(CultureInfo.InvariantCulture)); - return result.ToString(); } } From 2733e453553214990334bcaa9420ddfdfe3d253f Mon Sep 17 00:00:00 2001 From: Hash Date: Mon, 19 Oct 2026 02:42:07 +0300 Subject: [PATCH 7/8] chore: move version and add package icon --- ChessRealms.Engine.slnx | 3 +++ assets/chessrealms-emblem-128.png | Bin 0 -> 3394 bytes docs/releasing.md | 10 ++++++++++ scripts/Verify-Package.ps1 | 2 ++ src/ChessRealms.Engine/ChessRealms.Engine.csproj | 4 +++- 5 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 assets/chessrealms-emblem-128.png diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 569d885..7122c61 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -1,4 +1,7 @@ + + + diff --git a/assets/chessrealms-emblem-128.png b/assets/chessrealms-emblem-128.png new file mode 100644 index 0000000000000000000000000000000000000000..9494fb52297c531fcddfcfb58477d7033cd46a37 GIT binary patch literal 3394 zcmV-I4ZZS-P)6 zgMcU)UudaS+JZ%`6&)&Cw4&h+Ds6R&DB3}^VtorpipoP?MPACwS4BWk9zqZZ$#(ve z1VbQjPVU*=b5Hi0`Dc>M&E4Jq|IY57fB*kq$B7=AWv5!_#5nY#f(@Y@#t{zB5zgij z4y&nvEmW`_RIt5NfUFk$8UF@b@H6gb1w4!Yi{Ftg5#Eg*tTj0skfmCQTa8bmWl5jmM8Suq-%a9FR(*wiyA*g7iM z1j^x}+}zwBT0$rQW9$bwr4!Dk5e~ae8=W>p2Eld_&Yr|2LkRJGONKO&(>8t%;ZRCA z+h*Fh)c?$|4QHvWf;dorqvBQvp?`4M~F#&gRFG(A#bzkB?@ACoZELRv0&! zP$tW%i1)EXs0WgY_n?9;3xR>84k3aq#Z65rj0;NlR&V8gtJjC+P6Q&X$pv?;ulcPJ~XZPun7^cF`KjVok%-yIFwQW zm8O*)VyPkm9>p2bi4tPTL>tOsO$Zd65kdr86&e2l5$x(C4_>TLFFN3C9~EqXld&i% z`JU?|w+5GK>R_znIL)0*^W77{W?17xKr9 zFe6r&IJ4T_Y1etGLe>kta@)53S#@B9ee$Um z9k5NLfc@MF18`lk(>@rO3D`=KpZ^o{yM4}P+b17P%>-p8!_b<$k+XK%mxgul0{UFE zTi-f)AUw+fXQQ+lp9pr1=|Nk8x;m(;i8Ru5up?NxH2{Q*gdDzVnSw0!N4kQoGCo)< z@|a73fOnOKqZ1AfTjfKj35YQl>LPif5~dw(siLVwQSdQ*f^7=H;H(HwI#?U%Qc#^G zYn4wkNe3K;24Z}Ygg8d78Xu4q=}!l&oF?NW{ik2R>sI-Ykp#rL2mxg5m>-B0XtWNp zlkskiwSP0h*%GUK$ZP^~wxDrOKPGCL?z|Nlgbrdn-q|++BA%t&q=X1It1(U=qn+;5 zySu#O-r@3@e?Kpmu2?R=*#4#5vv-dS3KJild_;Trd%E~`|9-h+=MK4Y?P@vm)tBV( z2k(*-;JGY| zZkO-59g=eNBNN@HM;CMwpcY^6*dgy7`=D&uu8j!-RAmWhc|rnUaDmQ_T{&=o{P@#P zq;8lXKrP;1zfN|);!*=7U?Y!feDohv4qJ6}CYDOb36D;ehbk+j378~6?SA3byYJLX z01jJ^dq#kTjC6Dar@$F6&Xgu$ngEEZswz2h;ux(Yz#5AUPyd2FrQG-ht>tTTXG_xv z0|9Dr-?&npB$#@<0uIWHFh2dS88QqA69FnKE9HP8ztuzlwiCnok0wBp6{~K1$gtaG zSP&)xAn*;Su(+!h5}=>wSkwO`m5rX)>q1#xUM|Ceun_?9%Iw#5)*Pr$0N9|i(b@Cn z%En^fx8KTn3*M2_XFezY_QXE|k^X~jJfeU2d%E8_b^6ot^|^D>@@HHzaDWa1tUbEq zy21wgUEjY^%2@y5MmhM-+hnVbT-TE!gWBj`JNQPqY~>2yW1hcwp_V>oi&keHCIC&s zl`0#X`}RCvN~kz=NZvnww5v$d#-wS=u#qEVMMZ_L@ptRjOFaQn&`-jCnpQRc}@YuAep7pSH{RbKdz~2h(ZG&rZJnTL4uoi7tim ztFOQI)Hj?l+ji}!{BjGoYI(~f%T?6~Zr%^xY{p&*-2*5^T#_w~g8W=NqqUQm# z&IOStK(bpW&;7%PM*{(7pbPjdRSozti0<}G5TIMXUY`4R>u?>xBCO0&S;pNG0FKbd zEqC4FnE?F<-Jqq^97X_du>=4uGl17v2y}?$ckQ5K2H1>l;9Zsgpj_D)625cyE>8s5 z_|b=22#FVFJJlBxL}LjEvn%+%aiyMh@x(Jv>mooo65vo&2oP53a>+$p*akK&h45c0Ump5s+%Zl zV%+XwP*{$G&+pr_NfDr;qC$IO=rQjZpn?sFNzt?rg@&B~1GHrTdz0D=CYq^C3xC%~SopJP zhJ}Zn0AGH!U0VjQ=df@$F@&=Psv5#6&nxg89J6RL$rb$eq6N|q&_kG8m>&WHz&mwQ z!zQX~DC((UJ+AKS_Sp|Gi`aC(k4O=Z309)Av8X494jVZ_W`gIhu3#zP!vx4m=AEsw zvA)+|BhyI_H(X#*dJ){e_`&`I`(^KIOUx0V{@ufb!+MnszESq3Y%K5%;jM-9-2xG~ z4&%NeT?sn%Dz5{Vt3O!lYh0VReD0ngY9|1PHODFuqii35(y`|Sp)L_a1CXLid&qD0 z_^W3;fAJ#iOC1qx%uxiO94>0gMWSdxa>E~P@>L4}G5K)~^#D$Ei3Hw4tui>IR;E2W zU4{WF6GQ&~U{uvvZ)q|qydi+YyJ2EE|1+y`lu%WXH%SpkS!8D zb(M<6djL#Ab!u4Z!Y*>_7hm}5(?_3vEL#^9s@8w^1_|I46`iCqQ>d=s88~LZhNW0E zRrLCM@ZdpT{hISunY#FZ6BUcDUIw642LWmY>*68vbwgKj=*S6UeD`ha_otU%g2Ac8&Pqzm*55CI3Q zK_H_@NjQ9i_dqy7K=);mRv?I{|28Kmv}oV{6e3`wH4J11IXSF3q0iqhYMet_UBp(P zF*>O9?ey^q)nQ{+K>hu|>7XWR=sjyF2rF3^1wQrHM^-@`s9<|TFjy<%I~@=K+jHBs zZsF(vCKcHI0ECHvRW!veadZMxi$XAPE5g;m1C9YSM^BSA0+=NsXVcLZCJ5l*hJy(9 zh9v=*BEVeSm^&ts*D>$MRIp{11fbRUM6jidu~VHe;9PO;PYGx1ED1mp0obNkVd6|D zOd$DrRP_zyRsdi3Hn{iwg%M{Lv^pJ~5!O(kY}(Ih)8-5(%)oWYZVW-qhOb@jX-*~p zlMnIr#Lff|)SCZ1DIzC38HVOHaRl}RQ8e24RW(i?Gw=C9AeM?>KseiGX9MuL-oy4r zNArdacu0X|$t(%LoAEg;#J%NY^B2Dp92Jw$e-$ zBG@WrkvyRT)*;|<3r4P*Ry;K>tQ=>?2FtcY8Cai?a5k+8KlLx?2$tv13LrAh>Dqt!WIt+nv<*|kBpaLGptZMADX|+WX__`Dm zi3@9PJ6mhq=-l;bj767slYEsk4J><}oq~Rp!+6Tse3j*HiD0{2mo|s-Bnf>(7nNgC zCpsw=??E^WCW4Kwj$USEM6i`qu+2DC*L6Y>_h+2G+2)$hl;Lr9_5Z>kclilx6i%A> YKhm(_({ZUQ)c^nh07*qoM6N<$f>oDES^xk5 literal 0 HcmV?d00001 diff --git a/docs/releasing.md b/docs/releasing.md index bc575fd..081c72c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,16 @@ the leading `v`; its stable core must still match `VersionPrefix`. Manual runs validate, restore, build, test, pack, inspect, and consume the package, but cannot publish or create a GitHub Release. +## Package icon + +The package embeds `assets/chessrealms-emblem-128.png` as +`chessrealms-emblem-128.png` and references it through `PackageIcon`. This is the +unchanged 128x128 PNG emblem with transparent corners from the +[ChessRealms organization assets](https://github.com/ChessRealms/.github/blob/83657d5120b3ae8c0d2acdb5af59c2c079e7c518/profile/assets/png/chessrealms-emblem-128.png). +The local copy makes package builds independent of the branding repository's +availability. `Verify-Package.ps1` checks both the icon metadata and its presence +in the package. Keep those checks in sync if the filename changes. + ## Versioning policy - PATCH releases contain backward-compatible fixes and internal improvements. diff --git a/scripts/Verify-Package.ps1 b/scripts/Verify-Package.ps1 index eec9c9a..4f0a6e4 100644 --- a/scripts/Verify-Package.ps1 +++ b/scripts/Verify-Package.ps1 @@ -127,6 +127,7 @@ Assert-Equal "Package description" (Get-MetadataValue $package.Nuspec "descripti Assert-Equal "Package authors" (Get-MetadataValue $package.Nuspec "authors") "ChessRealms" Assert-Equal "Package project URL" (Get-MetadataValue $package.Nuspec "projectUrl") "https://github.com/ChessRealms/Engine" Assert-Equal "Package README" (Get-MetadataValue $package.Nuspec "readme") "README.md" +Assert-Equal "Package icon" (Get-MetadataValue $package.Nuspec "icon") "chessrealms-emblem-128.png" $license = $package.Nuspec.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']/*[local-name()='license']") if ($null -eq $license -or $license.GetAttribute("type") -cne "expression" -or $license.InnerText -cne "MIT") { @@ -145,6 +146,7 @@ $expectedPackageEntries = @( "$ExpectedPackageId.nuspec" "LICENSE.txt" "README.md" + "chessrealms-emblem-128.png" "lib/net10.0/$ExpectedPackageId.dll" "lib/net10.0/$ExpectedPackageId.xml" "[Content_Types].xml" diff --git a/src/ChessRealms.Engine/ChessRealms.Engine.csproj b/src/ChessRealms.Engine/ChessRealms.Engine.csproj index 346522c..afb7a7a 100644 --- a/src/ChessRealms.Engine/ChessRealms.Engine.csproj +++ b/src/ChessRealms.Engine/ChessRealms.Engine.csproj @@ -6,7 +6,7 @@ enable true ChessRealms.Engine - 1.0.0 + 1.1.0 ChessRealms.Engine A reusable .NET library for standard chess positions, legal move generation, game state, history, undo, FEN, and standard game-ending rules. ChessRealms @@ -16,6 +16,7 @@ https://github.com/ChessRealms/Engine.git git README.md + chessrealms-emblem-128.png true $(NoWarn);CS1591 true @@ -35,5 +36,6 @@ + From a18bef4b82cd6138e069b1de49dd98586a9a8a09 Mon Sep 17 00:00:00 2001 From: Hash Date: Mon, 19 Oct 2026 03:03:03 +0300 Subject: [PATCH 8/8] ci: use Node.js 24 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04e7206..d05d97f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: Build and test on: push: branches: - - "**" + - main pull_request: permissions: @@ -17,8 +17,8 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Verify solution structure