Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Build and test
on:
push:
branches:
- "**"
- main
pull_request:

permissions:
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type>/<short-description>` branch names and
`<type>(<scope>): <imperative summary>` commit and PR titles. Common types are
`feature` (title type `feat`), `fix`, `docs`, `test`, `refactor`, `perf`, `ci`
Expand Down
4 changes: 4 additions & 0 deletions ChessRealms.Engine.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<Solution>
<Folder Name="/assets/">
<File Path="assets/chessrealms-emblem-128.png" />
</Folder>
<Folder Name="/docs/" Id="a576d511-a7dd-48da-89d2-a534742656e6">
<File Path="docs/game-rules-api.md" />
<File Path="docs/performance.md" />
<File Path="docs/releasing.md" />
</Folder>
<Folder Name="/scripts/" Id="9f3eeaa3-c0d2-4d93-af81-f93234779361">
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -53,6 +57,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
Expand Down
Binary file added assets/chessrealms-emblem-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
114 changes: 114 additions & 0 deletions docs/game-rules-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
Expand All @@ -38,6 +45,113 @@ 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 and restoration

`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();
ChessGame restored = ChessGame.FromRecord(record);
```

`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.

`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.

## 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

FEN import requires exactly six fields separated by single ASCII spaces, with
Expand Down
76 changes: 76 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.401",
"version": "10.0.402",
"rollForward": "disable",
"allowPrerelease": false
},
Expand Down
2 changes: 2 additions & 0 deletions scripts/Verify-Package.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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"
Expand Down
Loading
Loading